mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 18:11:12 -05:00
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:
@@ -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 {
|
||||
|
||||
205
src/hooks/useTransactionResilience.ts
Normal file
205
src/hooks/useTransactionResilience.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user