Move orphaned operation recovery logic into a dedicated orphan-recovery module, introducing a periodic sweep timer that triggers recovery every 15 seconds while the worker is connected. Refactor runtime to delegate orphan detection and handling to the new module, improving modularity and maintainability. Add unit tests for orphan-recovery to ensure correctness.
100 lines
3.2 KiB
TypeScript
100 lines
3.2 KiB
TypeScript
import type {
|
|
PdfLayoutJobResult,
|
|
WhisperAlignJobResult,
|
|
WorkerJobTiming,
|
|
WorkerJobState,
|
|
WorkerOperationState,
|
|
} from '@openreader/compute-core/api-contracts';
|
|
|
|
export type StreamedOperationState = WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>;
|
|
|
|
export interface OrphanRecoveryStateStore {
|
|
getOpStateRecord(opId: string): Promise<{ state: StreamedOperationState; revision: number } | null>;
|
|
listOpStates(): Promise<StreamedOperationState[]>;
|
|
}
|
|
|
|
export interface OrphanRecoveryOrchestrator {
|
|
markFailedIfUnchanged(input: {
|
|
current: StreamedOperationState;
|
|
expectedRevision: number;
|
|
error: { message: string; code?: string } | string;
|
|
updatedAt?: number;
|
|
timing?: WorkerJobTiming;
|
|
}): Promise<StreamedOperationState | null>;
|
|
}
|
|
|
|
export interface RecoverOrphanedOperationsInput {
|
|
operationStateStore: OrphanRecoveryStateStore;
|
|
orchestrator: OrphanRecoveryOrchestrator;
|
|
whisperTimeoutMs: number;
|
|
pdfTimeoutMs: number;
|
|
opStaleMs: number;
|
|
nowMs?: number;
|
|
}
|
|
|
|
function isInflightStatus(status: WorkerJobState): boolean {
|
|
return status === 'queued' || status === 'running';
|
|
}
|
|
|
|
export function getOrphanRecoveryThresholdMs(input: {
|
|
state: StreamedOperationState;
|
|
whisperTimeoutMs: number;
|
|
pdfTimeoutMs: number;
|
|
opStaleMs: number;
|
|
}): number | null {
|
|
if (!isInflightStatus(input.state.status)) return null;
|
|
if (input.state.status === 'running') {
|
|
return input.state.kind === 'whisper_align' ? input.whisperTimeoutMs : input.pdfTimeoutMs;
|
|
}
|
|
if (input.state.kind !== 'pdf_layout') return null;
|
|
return input.opStaleMs;
|
|
}
|
|
|
|
export async function recoverOrphanedOperations(
|
|
input: RecoverOrphanedOperationsInput,
|
|
): Promise<Array<Pick<StreamedOperationState, 'opId' | 'kind' | 'status'>>> {
|
|
const nowMs = input.nowMs ?? Date.now();
|
|
const states = await input.operationStateStore.listOpStates();
|
|
const candidateStates = states.filter((state) => (
|
|
getOrphanRecoveryThresholdMs({
|
|
state,
|
|
whisperTimeoutMs: input.whisperTimeoutMs,
|
|
pdfTimeoutMs: input.pdfTimeoutMs,
|
|
opStaleMs: input.opStaleMs,
|
|
}) !== null
|
|
));
|
|
const recoveredStates: Array<Pick<StreamedOperationState, 'opId' | 'kind' | 'status'>> = [];
|
|
|
|
for (const candidate of candidateStates) {
|
|
const record = await input.operationStateStore.getOpStateRecord(candidate.opId);
|
|
if (!record) continue;
|
|
const staleAfterMs = getOrphanRecoveryThresholdMs({
|
|
state: record.state,
|
|
whisperTimeoutMs: input.whisperTimeoutMs,
|
|
pdfTimeoutMs: input.pdfTimeoutMs,
|
|
opStaleMs: input.opStaleMs,
|
|
});
|
|
if (staleAfterMs === null) continue;
|
|
const ageMs = nowMs - record.state.updatedAt;
|
|
if (ageMs <= staleAfterMs) continue;
|
|
|
|
const recovered = await input.orchestrator.markFailedIfUnchanged({
|
|
current: record.state,
|
|
expectedRevision: record.revision,
|
|
error: {
|
|
code: 'WORKER_ORPHANED_OP',
|
|
message: `Worker stopped before completion; stale operation recovered during reconciliation after ${staleAfterMs}ms`,
|
|
},
|
|
updatedAt: nowMs,
|
|
});
|
|
if (!recovered) continue;
|
|
|
|
recoveredStates.push({
|
|
opId: recovered.opId,
|
|
kind: recovered.kind,
|
|
status: record.state.status,
|
|
});
|
|
}
|
|
|
|
return recoveredStates;
|
|
}
|