mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:11:16 -05:00
206 lines
5.7 KiB
TypeScript
206 lines
5.7 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|