openreader/compute-worker/tests/compute/helpers/in-memory-control-plane.ts
Richard R 5403aa00cc refactor(compute-worker): modularize operation contracts and API, update protocol naming
- 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.
2026-06-13 05:04:24 -06:00

164 lines
5.1 KiB
TypeScript

import { EventEmitter } from 'node:events';
import type { WorkerOperationKind } from '../../../src/operations/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);
};
}
}