feat(worker): extract orphaned operation recovery to module with periodic sweep
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.
This commit is contained in:
parent
8913e5b592
commit
74a05a8de0
3 changed files with 212 additions and 65 deletions
100
compute/worker/src/orphan-recovery.ts
Normal file
100
compute/worker/src/orphan-recovery.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,10 @@ import {
|
||||||
hashOpKey,
|
hashOpKey,
|
||||||
} from './control-plane/jetstream';
|
} from './control-plane/jetstream';
|
||||||
import { type JsonCodec, createJsonCodec } from './control-plane/json-codec';
|
import { type JsonCodec, createJsonCodec } from './control-plane/json-codec';
|
||||||
|
import {
|
||||||
|
recoverOrphanedOperations,
|
||||||
|
type StreamedOperationState,
|
||||||
|
} from './orphan-recovery';
|
||||||
import { buildInferProgressForPageParsed, buildInferProgressForPageStart } from './pdf-progress';
|
import { buildInferProgressForPageParsed, buildInferProgressForPageStart } from './pdf-progress';
|
||||||
import { buildQueueWaitTiming, decideRetryAction } from './worker-loop-policy';
|
import { buildQueueWaitTiming, decideRetryAction } from './worker-loop-policy';
|
||||||
|
|
||||||
|
|
@ -79,6 +83,7 @@ const NATS_API_TIMEOUT_MS = 60_000;
|
||||||
// put it to sleep. Reconnect happens lazily on the next inbound request.
|
// put it to sleep. Reconnect happens lazily on the next inbound request.
|
||||||
const IDLE_DISCONNECT_MS = 120_000;
|
const IDLE_DISCONNECT_MS = 120_000;
|
||||||
const IDLE_CHECK_INTERVAL_MS = 5_000;
|
const IDLE_CHECK_INTERVAL_MS = 5_000;
|
||||||
|
const ORPHAN_SWEEP_INTERVAL_MS = 15_000;
|
||||||
// Bounded pull window so consumer loops yield periodically and can be stopped
|
// Bounded pull window so consumer loops yield periodically and can be stopped
|
||||||
// cleanly when going idle, instead of blocking on a long-lived pull.
|
// cleanly when going idle, instead of blocking on a long-lived pull.
|
||||||
const PULL_EXPIRES_MS = 5_000;
|
const PULL_EXPIRES_MS = 5_000;
|
||||||
|
|
@ -107,8 +112,6 @@ interface NatsSession {
|
||||||
layoutConsumer: Consumer;
|
layoutConsumer: Consumer;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamedOperationState = WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>;
|
|
||||||
|
|
||||||
interface OperationEventStreamLike {
|
interface OperationEventStreamLike {
|
||||||
subscribe(input: {
|
subscribe(input: {
|
||||||
opId: string;
|
opId: string;
|
||||||
|
|
@ -187,24 +190,6 @@ function requireEnv(name: string): string {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInflightStatus(status: WorkerJobState): boolean {
|
|
||||||
return status === 'queued' || status === 'running';
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readIntEnv(name: string, fallback: number): number {
|
function readIntEnv(name: string, fallback: number): number {
|
||||||
const raw = process.env[name]?.trim();
|
const raw = process.env[name]?.trim();
|
||||||
if (!raw) return fallback;
|
if (!raw) return fallback;
|
||||||
|
|
@ -568,6 +553,7 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
let connecting: Promise<NatsSession> | null = null;
|
let connecting: Promise<NatsSession> | null = null;
|
||||||
let workerLoops: Promise<void>[] = [];
|
let workerLoops: Promise<void>[] = [];
|
||||||
let idleTimer: NodeJS.Timeout | null = null;
|
let idleTimer: NodeJS.Timeout | null = null;
|
||||||
|
let orphanSweepTimer: NodeJS.Timeout | null = null;
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
let loopStopRequested = false;
|
let loopStopRequested = false;
|
||||||
|
|
||||||
|
|
@ -596,6 +582,19 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
idleTimer.unref?.();
|
idleTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startOrphanSweepTimer(): void {
|
||||||
|
if (orphanSweepTimer) return;
|
||||||
|
orphanSweepTimer = setInterval(() => {
|
||||||
|
if (!session || stopping) return;
|
||||||
|
void runOrphanedOpRecovery({ force: true }).catch((error) => {
|
||||||
|
app.log.error({
|
||||||
|
error: toErrorMessage(error),
|
||||||
|
}, 'periodic orphaned operation recovery failed');
|
||||||
|
});
|
||||||
|
}, ORPHAN_SWEEP_INTERVAL_MS);
|
||||||
|
orphanSweepTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
async function disconnect(reason: string): Promise<void> {
|
async function disconnect(reason: string): Promise<void> {
|
||||||
const current = session;
|
const current = session;
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
|
|
@ -607,6 +606,10 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
clearInterval(idleTimer);
|
clearInterval(idleTimer);
|
||||||
idleTimer = null;
|
idleTimer = null;
|
||||||
}
|
}
|
||||||
|
if (orphanSweepTimer) {
|
||||||
|
clearInterval(orphanSweepTimer);
|
||||||
|
orphanSweepTimer = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await current.nc.close();
|
await current.nc.close();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -649,6 +652,7 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
markActivity();
|
markActivity();
|
||||||
startWorkerLoops(next);
|
startWorkerLoops(next);
|
||||||
startIdleTimer();
|
startIdleTimer();
|
||||||
|
startOrphanSweepTimer();
|
||||||
// Safety net: if the connection closes for any reason (network drop after
|
// Safety net: if the connection closes for any reason (network drop after
|
||||||
// exhausting reconnects, or our own disconnect), drop the stale session so
|
// exhausting reconnects, or our own disconnect), drop the stale session so
|
||||||
// the next request reconnects cleanly.
|
// the next request reconnects cleanly.
|
||||||
|
|
@ -764,59 +768,35 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
let orphanRecoveryDoneForGeneration = -1;
|
let orphanRecoveryDoneForGeneration = -1;
|
||||||
let sessionGeneration = options.routeDeps ? 0 : -1;
|
let sessionGeneration = options.routeDeps ? 0 : -1;
|
||||||
|
|
||||||
const ensureOrphanedOpRecovery = async (): Promise<void> => {
|
const runOrphanedOpRecovery = async (options?: { force?: boolean }): Promise<void> => {
|
||||||
if (typeof operationStateStore.listOpStates !== 'function') return;
|
if (typeof operationStateStore.listOpStates !== 'function') return;
|
||||||
if (typeof operationStateStore.getOpStateRecord !== 'function') return;
|
if (typeof operationStateStore.getOpStateRecord !== 'function') return;
|
||||||
if (typeof orchestrator.markFailedIfUnchanged !== 'function') return;
|
if (typeof orchestrator.markFailedIfUnchanged !== 'function') return;
|
||||||
if (orphanRecoveryDoneForGeneration === sessionGeneration) return;
|
if (!options?.force && orphanRecoveryDoneForGeneration === sessionGeneration) return;
|
||||||
if (orphanRecoveryPromise) {
|
if (orphanRecoveryPromise) {
|
||||||
await orphanRecoveryPromise;
|
await orphanRecoveryPromise;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
orphanRecoveryPromise = (async () => {
|
orphanRecoveryPromise = (async () => {
|
||||||
const now = Date.now();
|
const recoveredStates = await recoverOrphanedOperations({
|
||||||
const states = await operationStateStore.listOpStates!();
|
operationStateStore: operationStateStore as typeof operationStateStore & {
|
||||||
const candidateStates = states.filter((state) => (
|
getOpStateRecord(opId: string): Promise<{ state: StreamedOperationState; revision: number } | null>;
|
||||||
getOrphanRecoveryThresholdMs({
|
listOpStates(): Promise<StreamedOperationState[]>;
|
||||||
state,
|
},
|
||||||
whisperTimeoutMs,
|
orchestrator: orchestrator as typeof orchestrator & {
|
||||||
pdfTimeoutMs,
|
markFailedIfUnchanged(input: {
|
||||||
opStaleMs,
|
current: StreamedOperationState;
|
||||||
}) !== null
|
expectedRevision: number;
|
||||||
));
|
error: { message: string; code?: string } | string;
|
||||||
const recoveredStates: Array<Pick<StreamedOperationState, 'opId' | 'kind' | 'status'>> = [];
|
updatedAt?: number;
|
||||||
|
timing?: WorkerJobTiming;
|
||||||
for (const candidate of candidateStates) {
|
}): Promise<StreamedOperationState | null>;
|
||||||
const record = await operationStateStore.getOpStateRecord!(candidate.opId);
|
},
|
||||||
if (!record) continue;
|
whisperTimeoutMs,
|
||||||
const staleAfterMs = getOrphanRecoveryThresholdMs({
|
pdfTimeoutMs,
|
||||||
state: record.state,
|
opStaleMs,
|
||||||
whisperTimeoutMs,
|
});
|
||||||
pdfTimeoutMs,
|
|
||||||
opStaleMs,
|
|
||||||
});
|
|
||||||
if (staleAfterMs === null) continue;
|
|
||||||
const ageMs = now - record.state.updatedAt;
|
|
||||||
if (ageMs <= staleAfterMs) continue;
|
|
||||||
|
|
||||||
const recovered = await orchestrator.markFailedIfUnchanged!({
|
|
||||||
current: record.state,
|
|
||||||
expectedRevision: record.revision,
|
|
||||||
error: {
|
|
||||||
code: 'WORKER_ORPHANED_OP',
|
|
||||||
message: `Worker stopped before completion; stale operation recovered on startup after ${staleAfterMs}ms`,
|
|
||||||
},
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
if (!recovered) continue;
|
|
||||||
|
|
||||||
recoveredStates.push({
|
|
||||||
opId: recovered.opId,
|
|
||||||
kind: recovered.kind,
|
|
||||||
status: record.state.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recoveredStates.length > 0) {
|
if (recoveredStates.length > 0) {
|
||||||
app.log.warn({
|
app.log.warn({
|
||||||
|
|
@ -826,7 +806,7 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
kind: state.kind,
|
kind: state.kind,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
})),
|
})),
|
||||||
}, 'recovered stale in-flight operations on startup');
|
}, 'recovered stale in-flight operations during reconciliation');
|
||||||
}
|
}
|
||||||
|
|
||||||
orphanRecoveryDoneForGeneration = sessionGeneration;
|
orphanRecoveryDoneForGeneration = sessionGeneration;
|
||||||
|
|
@ -837,6 +817,10 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
await orphanRecoveryPromise;
|
await orphanRecoveryPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureOrphanedOpRecovery = async (): Promise<void> => {
|
||||||
|
await runOrphanedOpRecovery();
|
||||||
|
};
|
||||||
|
|
||||||
const getOpState = async (opId: string): Promise<StreamedOperationState | null> => {
|
const getOpState = async (opId: string): Promise<StreamedOperationState | null> => {
|
||||||
await ensureOrphanedOpRecovery();
|
await ensureOrphanedOpRecovery();
|
||||||
return await operationStateStore.getOpState(opId);
|
return await operationStateStore.getOpState(opId);
|
||||||
|
|
@ -1509,6 +1493,10 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
|
||||||
clearInterval(idleTimer);
|
clearInterval(idleTimer);
|
||||||
idleTimer = null;
|
idleTimer = null;
|
||||||
}
|
}
|
||||||
|
if (orphanSweepTimer) {
|
||||||
|
clearInterval(orphanSweepTimer);
|
||||||
|
orphanSweepTimer = null;
|
||||||
|
}
|
||||||
await app.close();
|
await app.close();
|
||||||
await Promise.allSettled(workerLoops);
|
await Promise.allSettled(workerLoops);
|
||||||
const current = session;
|
const current = session;
|
||||||
|
|
|
||||||
59
compute/worker/tests/unit/orphan-recovery.test.ts
Normal file
59
compute/worker/tests/unit/orphan-recovery.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { recoverOrphanedOperations } from '../../src/orphan-recovery';
|
||||||
|
import { FakeControlPlane } from '../fixtures/fake-control-plane';
|
||||||
|
|
||||||
|
describe('orphan recovery', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovers a running whisper op when a later sweep crosses the timeout in the same session', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-06-03T08:00:00.000Z'));
|
||||||
|
|
||||||
|
const fake = new FakeControlPlane();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
fake.seedState({
|
||||||
|
opId: 'op-whisper-running',
|
||||||
|
opKey: 'k-whisper-running',
|
||||||
|
kind: 'whisper_align',
|
||||||
|
jobId: 'job-op-whisper-running',
|
||||||
|
status: 'running',
|
||||||
|
queuedAt: startedAt,
|
||||||
|
updatedAt: startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstSweep = await recoverOrphanedOperations({
|
||||||
|
operationStateStore: fake.deps.operationStateStore,
|
||||||
|
orchestrator: fake.deps.orchestrator,
|
||||||
|
whisperTimeoutMs: 30_000,
|
||||||
|
pdfTimeoutMs: 300_000,
|
||||||
|
opStaleMs: 1_800_000,
|
||||||
|
});
|
||||||
|
expect(firstSweep).toEqual([]);
|
||||||
|
expect(fake.getState('op-whisper-running')).toMatchObject({
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(31_000);
|
||||||
|
|
||||||
|
const secondSweep = await recoverOrphanedOperations({
|
||||||
|
operationStateStore: fake.deps.operationStateStore,
|
||||||
|
orchestrator: fake.deps.orchestrator,
|
||||||
|
whisperTimeoutMs: 30_000,
|
||||||
|
pdfTimeoutMs: 300_000,
|
||||||
|
opStaleMs: 1_800_000,
|
||||||
|
});
|
||||||
|
expect(secondSweep).toEqual([{
|
||||||
|
opId: 'op-whisper-running',
|
||||||
|
kind: 'whisper_align',
|
||||||
|
status: 'running',
|
||||||
|
}]);
|
||||||
|
expect(fake.getState('op-whisper-running')).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
error: {
|
||||||
|
code: 'WORKER_ORPHANED_OP',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue