Implement Phase 4: Transaction Resilience

This commit implements Phase 4 of the Sacred Pipeline, focusing on transaction resilience. It introduces:

- **Timeout Detection & Recovery**: New utilities in `src/lib/timeoutDetection.ts` to detect, categorize (minor, moderate, critical), and provide recovery strategies for timeouts across various sources (fetch, Supabase, edge functions, database). Includes a `withTimeout` wrapper.
- **Lock Auto-Release**: Implemented in `src/lib/moderation/lockAutoRelease.ts` to automatically release submission locks on error, timeout, abandonment, or inactivity. Includes mechanisms for unload events and inactivity monitoring.
- **Idempotency Key Lifecycle Management**: A new module `src/lib/idempotencyLifecycle.ts` to track idempotency keys through their states (pending, processing, completed, failed, expired) using IndexedDB. Includes automatic cleanup of expired keys.
- **Enhanced Idempotency Helpers**: Updated `src/lib/idempotencyHelpers.ts` to integrate with the new lifecycle management, providing functions to generate, register, validate, and update the status of idempotency keys.
- **Transaction Resilience Hook**: A new hook `src/hooks/useTransactionResilience.ts` that combines timeout handling, lock auto-release, and idempotency key management for robust transaction execution.
- **Submission Queue Integration**: Updated `src/hooks/useSubmissionQueue.ts` to leverage the new submission queue and idempotency lifecycle functionalities.
- **Documentation**: Added `PHASE4_TRANSACTION_RESILIENCE.md` detailing the implemented features and their usage.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-07 15:03:12 +00:00
parent 095278dafd
commit 34dbe2e262
7 changed files with 1397 additions and 12 deletions

View File

@@ -1,9 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { QueuedSubmission } from '@/components/submission/SubmissionQueueIndicator';
import { useNetworkStatus } from './useNetworkStatus';
// This is a placeholder implementation
// In a real app, this would interact with IndexedDB and the actual submission system
import {
getPendingSubmissions,
processQueue,
removeFromQueue,
clearQueue as clearQueueStorage,
getPendingCount,
} from '@/lib/submissionQueue';
import { logger } from '@/lib/logger';
interface UseSubmissionQueueOptions {
autoRetry?: boolean;
@@ -42,13 +47,24 @@ export function useSubmissionQueue(options: UseSubmissionQueueOptions = {}) {
}, [isOnline, autoRetry, queuedItems.length, retryDelayMs]);
const loadQueueFromStorage = useCallback(async () => {
// Placeholder: Load from IndexedDB
// In real implementation, this would query the offline queue
try {
// const items = await getQueuedSubmissions();
// setQueuedItems(items);
const pending = await getPendingSubmissions();
// Transform to QueuedSubmission format
const items: QueuedSubmission[] = pending.map(item => ({
id: item.id,
type: item.type,
entityName: item.data?.name || item.data?.title || 'Unknown',
timestamp: new Date(item.timestamp),
status: item.retries >= 3 ? 'failed' : (item.lastAttempt ? 'retrying' : 'pending'),
retryCount: item.retries,
error: item.error || undefined,
}));
setQueuedItems(items);
logger.info('[SubmissionQueue] Loaded queue', { count: items.length });
} catch (error) {
console.error('Failed to load queue:', error);
logger.error('[SubmissionQueue] Failed to load queue', { error });
}
}, []);
@@ -97,13 +113,24 @@ export function useSubmissionQueue(options: UseSubmissionQueueOptions = {}) {
}
}, [queuedItems, maxRetries, retryItem]);
const removeItem = useCallback((id: string) => {
setQueuedItems(prev => prev.filter(item => item.id !== id));
const removeItem = useCallback(async (id: string) => {
try {
await removeFromQueue(id);
setQueuedItems(prev => prev.filter(item => item.id !== id));
logger.info('[SubmissionQueue] Removed item', { id });
} catch (error) {
logger.error('[SubmissionQueue] Failed to remove item', { id, error });
}
}, []);
const clearQueue = useCallback(async () => {
// Placeholder: Clear from IndexedDB
setQueuedItems([]);
try {
const count = await clearQueueStorage();
setQueuedItems([]);
logger.info('[SubmissionQueue] Cleared queue', { count });
} catch (error) {
logger.error('[SubmissionQueue] Failed to clear queue', { error });
}
}, []);
return {

View File

@@ -0,0 +1,205 @@
/**
* Transaction Resilience Hook
*
* Combines timeout detection, lock auto-release, and idempotency lifecycle
* into a unified hook for moderation transactions.
*
* Part of Sacred Pipeline Phase 4: Transaction Resilience
*/
import { useEffect, useCallback, useRef } from 'react';
import { useAuth } from '@/hooks/useAuth';
import {
withTimeout,
isTimeoutError,
getTimeoutErrorMessage,
type TimeoutError,
} from '@/lib/timeoutDetection';
import {
autoReleaseLockOnError,
setupAutoReleaseOnUnload,
setupInactivityAutoRelease,
} from '@/lib/moderation/lockAutoRelease';
import {
generateAndRegisterKey,
validateAndStartProcessing,
markKeyCompleted,
markKeyFailed,
is409Conflict,
getRetryAfter,
sleep,
} from '@/lib/idempotencyHelpers';
import { toast } from '@/hooks/use-toast';
import { logger } from '@/lib/logger';
interface TransactionResilientOptions {
submissionId: string;
/** Timeout in milliseconds (default: 30000) */
timeoutMs?: number;
/** Enable auto-release on unload (default: true) */
autoReleaseOnUnload?: boolean;
/** Enable inactivity auto-release (default: true) */
autoReleaseOnInactivity?: boolean;
/** Inactivity timeout in minutes (default: 10) */
inactivityMinutes?: number;
}
export function useTransactionResilience(options: TransactionResilientOptions) {
const { submissionId, timeoutMs = 30000, autoReleaseOnUnload = true, autoReleaseOnInactivity = true, inactivityMinutes = 10 } = options;
const { user } = useAuth();
const cleanupFnsRef = useRef<Array<() => void>>([]);
// Setup auto-release mechanisms
useEffect(() => {
if (!user?.id) return;
const cleanupFns: Array<() => void> = [];
// Setup unload auto-release
if (autoReleaseOnUnload) {
const cleanup = setupAutoReleaseOnUnload(submissionId, user.id);
cleanupFns.push(cleanup);
}
// Setup inactivity auto-release
if (autoReleaseOnInactivity) {
const cleanup = setupInactivityAutoRelease(submissionId, user.id, inactivityMinutes);
cleanupFns.push(cleanup);
}
cleanupFnsRef.current = cleanupFns;
// Cleanup on unmount
return () => {
cleanupFns.forEach(fn => fn());
};
}, [submissionId, user?.id, autoReleaseOnUnload, autoReleaseOnInactivity, inactivityMinutes]);
/**
* Execute a transaction with full resilience (timeout, idempotency, auto-release)
*/
const executeTransaction = useCallback(
async <T,>(
action: 'approval' | 'rejection' | 'retry',
itemIds: string[],
transactionFn: (idempotencyKey: string) => Promise<T>
): Promise<T> => {
if (!user?.id) {
throw new Error('User not authenticated');
}
// Generate and register idempotency key
const { key: idempotencyKey } = await generateAndRegisterKey(
action,
submissionId,
itemIds,
user.id
);
logger.info('[TransactionResilience] Starting transaction', {
action,
submissionId,
itemIds,
idempotencyKey,
});
try {
// Validate key and mark as processing
const isValid = await validateAndStartProcessing(idempotencyKey);
if (!isValid) {
throw new Error('Idempotency key validation failed - possible duplicate request');
}
// Execute transaction with timeout
const result = await withTimeout(
() => transactionFn(idempotencyKey),
timeoutMs,
'edge-function'
);
// Mark key as completed
await markKeyCompleted(idempotencyKey);
logger.info('[TransactionResilience] Transaction completed', {
action,
submissionId,
idempotencyKey,
});
return result;
} catch (error) {
// Check for timeout
if (isTimeoutError(error)) {
const timeoutError = error as TimeoutError;
const message = getTimeoutErrorMessage(timeoutError);
logger.error('[TransactionResilience] Transaction timed out', {
action,
submissionId,
idempotencyKey,
duration: timeoutError.duration,
});
// Auto-release lock on timeout
await autoReleaseLockOnError(submissionId, user.id, error);
// Mark key as failed
await markKeyFailed(idempotencyKey, message);
toast({
title: 'Transaction Timeout',
description: message,
variant: 'destructive',
});
throw timeoutError;
}
// Check for 409 Conflict (duplicate request)
if (is409Conflict(error)) {
const retryAfter = getRetryAfter(error);
logger.warn('[TransactionResilience] Duplicate request detected', {
action,
submissionId,
idempotencyKey,
retryAfter,
});
toast({
title: 'Duplicate Request',
description: `This action is already being processed. Please wait ${retryAfter}s.`,
});
// Wait and return (don't auto-release, the other request is handling it)
await sleep(retryAfter * 1000);
throw error;
}
// Generic error handling
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[TransactionResilience] Transaction failed', {
action,
submissionId,
idempotencyKey,
error: errorMessage,
});
// Auto-release lock on error
await autoReleaseLockOnError(submissionId, user.id, error);
// Mark key as failed
await markKeyFailed(idempotencyKey, errorMessage);
throw error;
}
},
[submissionId, user?.id, timeoutMs]
);
return {
executeTransaction,
};
}