openreader/compute-worker/tests/compute/helpers/in-memory-control-plane.ts
Richard R 3d5dfd2a88 refactor(compute-worker): migrate compute logic to modular architecture
- Remove legacy compute-worker monolith under `src/compute/`, including PDF and Whisper inference, control-plane, and runtime orchestration
- Move PDF and Whisper inference logic to new `src/inference/` module
- Move control-plane, orchestrator, and state machine logic to `src/operations/`
- Move NATS/JetStream adapters to `src/infrastructure/`
- Move job orchestration, progress, and artifact persistence to `src/jobs/`
- Update imports throughout tests and main app to reference new module structure
- Update Dockerfile and scripts to use new asset paths
- Add new entrypoints: `src/api/app.ts` and `src/api/contracts.ts`
- Remove obsolete files and update `.gitignore` for new dev artifacts

This refactor modularizes the compute-worker, improving maintainability and separation of concerns. No functional changes to inference or orchestration logic.

BREAKING CHANGE: compute-worker internal APIs, directory structure, and imports have changed; downstream code must update imports and integration points.
2026-06-12 14:07:15 -06:00

164 lines
5.1 KiB
TypeScript

import { EventEmitter } from 'node:events';
import type { WorkerOperationKind } from '../../../src/api/contracts';
import type {
OperationEvent,
OperationEventStream,
OperationIndexEntry,
OperationQueue,
OperationState,
OperationStateStore,
QueuedOperation,
} from '../../../src/operations/types';
function topicFor(opId: string): string {
return `op.${opId}`;
}
function normalizeSinceEventId(value: number | undefined): number {
if (!Number.isFinite(value ?? 0)) return 0;
return Math.max(0, Math.floor(value ?? 0));
}
export class InMemoryOperationQueue implements OperationQueue {
private readonly byKind = new Map<WorkerOperationKind, QueuedOperation[]>();
constructor() {
this.byKind.set('whisper_align', []);
this.byKind.set('pdf_layout', []);
}
async enqueue(job: QueuedOperation): Promise<void> {
const list = this.byKind.get(job.kind);
if (!list) throw new Error(`Unsupported operation kind: ${job.kind}`);
list.push(job);
}
async claimNext(kind: WorkerOperationKind): Promise<QueuedOperation | null> {
const list = this.byKind.get(kind);
if (!list || list.length === 0) return null;
return list.shift() ?? null;
}
size(kind?: WorkerOperationKind): number {
if (kind) return this.byKind.get(kind)?.length ?? 0;
let total = 0;
for (const list of this.byKind.values()) total += list.length;
return total;
}
}
export class InMemoryOperationStateStore implements OperationStateStore {
private readonly stateByOpId = new Map<string, OperationState>();
private readonly revisionByOpId = new Map<string, number>();
private readonly opIndexByKey = new Map<string, string>();
async getOpState(opId: string): Promise<OperationState | null> {
return this.stateByOpId.get(opId) ?? null;
}
async getOpStateRecord(opId: string): Promise<{ state: OperationState; revision: number } | null> {
const state = this.stateByOpId.get(opId);
if (!state) return null;
return {
state,
revision: this.revisionByOpId.get(opId) ?? 0,
};
}
async putOpState(state: OperationState): Promise<void> {
this.stateByOpId.set(state.opId, state);
this.revisionByOpId.set(state.opId, (this.revisionByOpId.get(state.opId) ?? 0) + 1);
}
async compareAndSetOpState(input: {
opId: string;
expectedRevision: number;
newState: OperationState;
}): Promise<boolean> {
const currentState = this.stateByOpId.get(input.opId);
if (!currentState) return false;
const currentRevision = this.revisionByOpId.get(input.opId) ?? 0;
if (currentRevision !== input.expectedRevision) return false;
this.stateByOpId.set(input.opId, input.newState);
this.revisionByOpId.set(input.opId, currentRevision + 1);
return true;
}
async getOpIndex(opKey: string): Promise<OperationIndexEntry | null> {
const opId = this.opIndexByKey.get(opKey);
return opId ? { opId } : null;
}
async compareAndSetOpIndex(input: {
opKey: string;
newOpId: string;
expectedOpId: string | null;
}): Promise<boolean> {
const current = this.opIndexByKey.get(input.opKey) ?? null;
if (current !== input.expectedOpId) return false;
this.opIndexByKey.set(input.opKey, input.newOpId);
return true;
}
}
export class InMemoryOperationEventStream implements OperationEventStream {
private readonly emitter = new EventEmitter();
private readonly lastIdByOpId = new Map<string, number>();
private readonly eventsByOpId = new Map<string, OperationEvent[]>();
constructor() {
this.emitter.setMaxListeners(0);
}
async append(opId: string, snapshot: OperationState): Promise<OperationEvent> {
const nextEventId = (this.lastIdByOpId.get(opId) ?? 0) + 1;
this.lastIdByOpId.set(opId, nextEventId);
const event: OperationEvent = {
eventId: nextEventId,
snapshot,
};
const list = this.eventsByOpId.get(opId) ?? [];
list.push(event);
this.eventsByOpId.set(opId, list);
this.emitter.emit(topicFor(opId), event);
return event;
}
async listSince(opId: string, sinceEventId: number, limit?: number): Promise<OperationEvent[]> {
const list = this.eventsByOpId.get(opId) ?? [];
const normalizedSince = normalizeSinceEventId(sinceEventId);
const filtered = list.filter((event) => event.eventId > normalizedSince);
if (!Number.isFinite(limit ?? 0) || !limit || limit <= 0) return filtered;
return filtered.slice(0, Math.floor(limit));
}
async subscribe(input: {
opId: string;
sinceEventId?: number;
onEvent: (event: OperationEvent) => void | Promise<void>;
onError?: (error: unknown) => void;
}): Promise<() => void> {
const replay = await this.listSince(input.opId, normalizeSinceEventId(input.sinceEventId));
for (const event of replay) {
try {
await input.onEvent(event);
} catch (error) {
input.onError?.(error);
}
}
const listener = (event: OperationEvent): void => {
Promise.resolve(input.onEvent(event)).catch((error) => {
input.onError?.(error);
});
};
const topic = topicFor(input.opId);
this.emitter.on(topic, listener);
return () => {
this.emitter.off(topic, listener);
};
}
}