- Move operation contracts from api/contracts to operations/contracts for clearer separation - Delete legacy API modules: contracts.ts, operation-keys.ts, public-operation.ts - Add new modular files: compute-operation.ts, http-hooks.ts, routes.ts, operations/keys.ts, operations/reconciliation.ts - Refactor all imports to use new operations/contracts and keys modules - Rename protocol types from PublicOperation to ComputeOperation for clarity - Update OpenAPI schema and all references to use ComputeOperation/Event naming - Extract whisper decoder helpers to inference/whisper/decoder.ts - Add new infra: nats-session.ts for NATS session management - Update tests and storage logic to use new contracts and keys modules - Refactor worker loop and orchestrator to use modular job definitions - Update client and protocol in src/lib/server/compute-worker to use new ComputeOperation types This refactor modularizes compute-worker operation logic, clarifies type naming, and improves maintainability by separating contracts, keys, and reconciliation logic. No breaking changes to external API, but all internal references and tests are updated for the new structure.
170 lines
5.1 KiB
TypeScript
170 lines
5.1 KiB
TypeScript
import type { Consumer, JsMsg } from '@nats-io/jetstream';
|
|
import { describe, expect, test, vi } from 'vitest';
|
|
import type { PdfLayoutProgress } from '../../src/operations/contracts';
|
|
import { createJsonCodec } from '../../src/infrastructure/json-codec';
|
|
import {
|
|
createWorkerLoopController,
|
|
type QueuedJob,
|
|
type WorkerLoopOrchestrator,
|
|
} from '../../src/jobs/worker-loop';
|
|
|
|
function createMessage<T>(job: QueuedJob<T>, deliveryCount = 1) {
|
|
const codec = createJsonCodec<QueuedJob<T>>();
|
|
const ack = vi.fn();
|
|
const nak = vi.fn();
|
|
const term = vi.fn();
|
|
const working = vi.fn();
|
|
return {
|
|
codec,
|
|
msg: {
|
|
data: codec.encode(job),
|
|
info: { deliveryCount },
|
|
ack,
|
|
nak,
|
|
term,
|
|
working,
|
|
} as unknown as JsMsg,
|
|
ack,
|
|
nak,
|
|
term,
|
|
working,
|
|
};
|
|
}
|
|
|
|
function createConsumer(message?: JsMsg): Consumer {
|
|
let delivered = false;
|
|
return {
|
|
next: async () => {
|
|
if (!delivered && message) {
|
|
delivered = true;
|
|
return message;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
return null;
|
|
},
|
|
} as unknown as Consumer;
|
|
}
|
|
|
|
function createOrchestrator() {
|
|
const calls: Array<{ method: string; input: unknown }> = [];
|
|
const record = (method: string) => async (input: unknown) => {
|
|
calls.push({ method, input });
|
|
return input;
|
|
};
|
|
const orchestrator: WorkerLoopOrchestrator = {
|
|
markRunning: record('running'),
|
|
markProgress: record('progress'),
|
|
markSucceeded: record('succeeded'),
|
|
markFailed: record('failed'),
|
|
};
|
|
return { orchestrator, calls };
|
|
}
|
|
|
|
describe('worker loop controller', () => {
|
|
test('processes PDF progress, persists success, ACKs, and releases in-flight activity', async () => {
|
|
const owner = {};
|
|
let active = true;
|
|
let inFlight = 0;
|
|
const { orchestrator, calls } = createOrchestrator();
|
|
let complete!: () => void;
|
|
const completed = new Promise<void>((resolve) => { complete = resolve; });
|
|
const progress: PdfLayoutProgress = { totalPages: 2, pagesParsed: 1, currentPage: 1, phase: 'infer' };
|
|
const pdf = createMessage({
|
|
jobId: 'job-pdf',
|
|
opId: 'op-pdf',
|
|
opKey: 'pdf-key',
|
|
kind: 'pdf_layout',
|
|
queuedAt: Date.now() - 10,
|
|
payload: {
|
|
documentId: 'a'.repeat(64),
|
|
namespace: null,
|
|
documentObjectKey: 'openreader/doc.pdf',
|
|
},
|
|
});
|
|
const whisperCodec = createJsonCodec<QueuedJob<{
|
|
text: string;
|
|
audioObjectKey: string;
|
|
}>>();
|
|
const controller = createWorkerLoopController({
|
|
orchestrator,
|
|
handlers: {
|
|
runWhisper: async () => ({ alignments: [] }),
|
|
runPdfLayout: async (_payload, _queueWaitMs, hooks) => {
|
|
await hooks?.onProgress?.(progress);
|
|
active = false;
|
|
complete();
|
|
return { parsedObjectKey: 'openreader/parsed.json' };
|
|
},
|
|
},
|
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
jobConcurrency: 1,
|
|
pdfAttempts: 2,
|
|
whisperCodec,
|
|
pdfCodec: pdf.codec,
|
|
isOwnerActive: () => active,
|
|
isStopping: () => false,
|
|
markActivity: vi.fn(),
|
|
onInFlightJobsChanged: (delta) => { inFlight += delta; },
|
|
});
|
|
|
|
controller.start(owner, { whisper: createConsumer(), pdfLayout: createConsumer(pdf.msg) });
|
|
await completed;
|
|
await controller.stop();
|
|
|
|
expect(pdf.working).toHaveBeenCalledOnce();
|
|
expect(pdf.ack).toHaveBeenCalledOnce();
|
|
expect(pdf.nak).not.toHaveBeenCalled();
|
|
expect(inFlight).toBe(0);
|
|
expect(calls.map((call) => call.method)).toContain('progress');
|
|
expect(calls.map((call) => call.method)).toContain('succeeded');
|
|
});
|
|
|
|
test('NAKs a retryable PDF failure and does not mark it terminal', async () => {
|
|
const owner = {};
|
|
let active = true;
|
|
const { orchestrator, calls } = createOrchestrator();
|
|
let attempted!: () => void;
|
|
const attemptCompleted = new Promise<void>((resolve) => { attempted = resolve; });
|
|
const pdf = createMessage({
|
|
jobId: 'job-pdf',
|
|
opId: 'op-pdf',
|
|
opKey: 'pdf-key',
|
|
kind: 'pdf_layout',
|
|
queuedAt: Date.now(),
|
|
payload: {
|
|
documentId: 'a'.repeat(64),
|
|
namespace: null,
|
|
documentObjectKey: 'openreader/doc.pdf',
|
|
},
|
|
});
|
|
const controller = createWorkerLoopController({
|
|
orchestrator,
|
|
handlers: {
|
|
runWhisper: async () => ({ alignments: [] }),
|
|
runPdfLayout: async () => {
|
|
active = false;
|
|
attempted();
|
|
throw new Error('retry me');
|
|
},
|
|
},
|
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
jobConcurrency: 1,
|
|
pdfAttempts: 2,
|
|
whisperCodec: createJsonCodec(),
|
|
pdfCodec: pdf.codec,
|
|
isOwnerActive: () => active,
|
|
isStopping: () => false,
|
|
markActivity: vi.fn(),
|
|
onInFlightJobsChanged: vi.fn(),
|
|
});
|
|
|
|
controller.start(owner, { whisper: createConsumer(), pdfLayout: createConsumer(pdf.msg) });
|
|
await attemptCompleted;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await controller.stop();
|
|
|
|
expect(pdf.nak).toHaveBeenCalledOnce();
|
|
expect(pdf.term).not.toHaveBeenCalled();
|
|
expect(calls.map((call) => call.method)).not.toContain('failed');
|
|
});
|
|
});
|