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.
This commit is contained in:
parent
a56aaa2be9
commit
3d5dfd2a88
75 changed files with 810 additions and 860 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -56,5 +56,7 @@ node_modules/
|
||||||
# vscode
|
# vscode
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
# .agents
|
# Agents
|
||||||
.agents
|
.agents
|
||||||
|
.codex
|
||||||
|
.claude
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses
|
||||||
# Include SeaweedFS license text for the copied weed binary.
|
# Include SeaweedFS license text for the copied weed binary.
|
||||||
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
|
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
|
||||||
# Include static model notices for runtime-downloaded assets.
|
# Include static model notices for runtime-downloaded assets.
|
||||||
COPY --from=app-builder /app/compute-worker/src/compute/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
|
COPY --from=app-builder /app/compute-worker/src/inference/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
|
||||||
|
|
||||||
# Copy seaweedfs weed binary for optional embedded local S3.
|
# Copy seaweedfs weed binary for optional embedded local S3.
|
||||||
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
|
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
|
||||||
|
|
@ -97,7 +97,7 @@ COPY --from=nats-builder /tmp/nats-server /usr/local/bin/nats-server
|
||||||
RUN chmod +x /usr/local/bin/nats-server
|
RUN chmod +x /usr/local/bin/nats-server
|
||||||
|
|
||||||
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
|
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
|
||||||
COPY --from=app-builder /app/compute-worker/src/compute/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
|
COPY --from=app-builder /app/compute-worker/src/inference/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
|
||||||
|
|
||||||
# Match the app's historical container port now that standalone server.js
|
# Match the app's historical container port now that standalone server.js
|
||||||
# is started directly instead of `next start -p 3003`.
|
# is started directly instead of `next start -p 3003`.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { createComputeWorkerApp } from '../src/runtime';
|
import { createComputeWorkerApp } from '../src/api/app';
|
||||||
|
|
||||||
process.env.COMPUTE_WORKER_TOKEN ||= 'openapi-generation-token';
|
process.env.COMPUTE_WORKER_TOKEN ||= 'openapi-generation-token';
|
||||||
process.env.NATS_URL ||= 'nats://127.0.0.1:4222';
|
process.env.NATS_URL ||= 'nats://127.0.0.1:4222';
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +1,24 @@
|
||||||
import type { TTSSentenceAlignment } from '../types/tts';
|
import type { TTSSentenceAlignment } from '../inference/types/tts';
|
||||||
import type { ParsedPdfDocument } from '../types/parsed-pdf';
|
import type { ParsedPdfDocument } from '../inference/types/parsed-pdf';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
TTSAudioBuffer,
|
TTSAudioBuffer,
|
||||||
TTSAudioBytes,
|
TTSAudioBytes,
|
||||||
TTSSentenceAlignment,
|
TTSSentenceAlignment,
|
||||||
TTSSentenceWord,
|
TTSSentenceWord,
|
||||||
} from '../types/tts';
|
} from '../inference/types/tts';
|
||||||
export type {
|
export type {
|
||||||
ParsedPdfBlockKind,
|
ParsedPdfBlockKind,
|
||||||
ParsedPdfBlockFragment,
|
ParsedPdfBlockFragment,
|
||||||
ParsedPdfBlock,
|
ParsedPdfBlock,
|
||||||
ParsedPdfPage,
|
ParsedPdfPage,
|
||||||
ParsedPdfDocument,
|
ParsedPdfDocument,
|
||||||
} from '../types/parsed-pdf';
|
} from '../inference/types/parsed-pdf';
|
||||||
|
|
||||||
export const ALIGN_QUEUE_NAME = 'whisper-align';
|
export const ALIGN_QUEUE_NAME = 'whisper-align';
|
||||||
export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout';
|
export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout';
|
||||||
export { PDF_PARSER_VERSION } from '../pdf/parser-version';
|
export { PDF_PARSER_VERSION } from '../inference/pdf/parser-version';
|
||||||
export { encodeParserVersion } from '../pdf/parser-version-key';
|
export { encodeParserVersion } from '../inference/pdf/parser-version-key';
|
||||||
|
|
||||||
export interface WhisperAlignJobBase {
|
export interface WhisperAlignJobBase {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { PDF_PARSER_VERSION } from '../compute/pdf/parser-version';
|
import { PDF_PARSER_VERSION } from '../inference/pdf/parser-version';
|
||||||
|
|
||||||
function sha256Hex(input: string): string {
|
function sha256Hex(input: string): string {
|
||||||
return createHash('sha256').update(input).digest('hex');
|
return createHash('sha256').update(input).digest('hex');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { WorkerOperationState } from '../compute/api-contracts';
|
import type { WorkerOperationState } from '../api/contracts';
|
||||||
import { pdfSubjectFromOperationKey } from './operation-keys';
|
import { pdfSubjectFromOperationKey } from './operation-keys';
|
||||||
|
|
||||||
export type PublicOperationSubject =
|
export type PublicOperationSubject =
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export * from './api-contracts';
|
export * from '../api/contracts';
|
||||||
export {
|
export {
|
||||||
getComputeJobConcurrency,
|
getComputeJobConcurrency,
|
||||||
getAvailableCpuCores,
|
getAvailableCpuCores,
|
||||||
|
|
@ -23,4 +23,4 @@ export { normalizeTextItemsForLayout } from './pdf/normalize-text';
|
||||||
export { mapWordsToSentenceOffsets, type WhisperWord } from './whisper/alignment-map';
|
export { mapWordsToSentenceOffsets, type WhisperWord } from './whisper/alignment-map';
|
||||||
export { buildGoertzelCoefficients, goertzelPower } from './whisper/spectral';
|
export { buildGoertzelCoefficients, goertzelPower } from './whisper/spectral';
|
||||||
export { buildWordsFromTimestampedTokens, extractTokenStartTimestamps } from './whisper/token-timestamps';
|
export { buildWordsFromTimestampedTokens, extractTokenStartTimestamps } from './whisper/token-timestamps';
|
||||||
export * from './control-plane';
|
export * from '../operations';
|
||||||
|
|
@ -8,12 +8,12 @@ import type {
|
||||||
OperationState,
|
OperationState,
|
||||||
OperationStateStore,
|
OperationStateStore,
|
||||||
QueuedOperation,
|
QueuedOperation,
|
||||||
} from '../compute/control-plane';
|
} from '../operations';
|
||||||
import type {
|
import type {
|
||||||
PdfLayoutJobRequest,
|
PdfLayoutJobRequest,
|
||||||
WhisperAlignJobRequest,
|
WhisperAlignJobRequest,
|
||||||
WorkerOperationKind,
|
WorkerOperationKind,
|
||||||
} from '../compute/api-contracts';
|
} from '../api/contracts';
|
||||||
import { createJsonCodec } from './json-codec';
|
import { createJsonCodec } from './json-codec';
|
||||||
|
|
||||||
export interface KvEntryLike {
|
export interface KvEntryLike {
|
||||||
104
compute-worker/src/infrastructure/nats.ts
Normal file
104
compute-worker/src/infrastructure/nats.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import {
|
||||||
|
AckPolicy,
|
||||||
|
DeliverPolicy,
|
||||||
|
ReplayPolicy,
|
||||||
|
RetentionPolicy,
|
||||||
|
StorageType,
|
||||||
|
type JetStreamManager,
|
||||||
|
} from '@nats-io/jetstream';
|
||||||
|
import { nanos } from '@nats-io/transport-node';
|
||||||
|
import { OP_EVENTS_SUBJECT_WILDCARD } from './nats-adapters';
|
||||||
|
|
||||||
|
export const JOBS_STREAM_NAME = 'compute_jobs';
|
||||||
|
export const WHISPER_JOBS_SUBJECT = 'jobs.whisper';
|
||||||
|
export const LAYOUT_JOBS_SUBJECT = 'jobs.layout';
|
||||||
|
export const WHISPER_CONSUMER_NAME = 'compute_whisper';
|
||||||
|
export const LAYOUT_CONSUMER_NAME = 'compute_layout';
|
||||||
|
export const EVENTS_STREAM_NAME = 'compute_events';
|
||||||
|
export const COMPUTE_STATE_BUCKET = 'compute_state';
|
||||||
|
export const COMPUTE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
export const NATS_API_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
const WHISPER_MAX_DELIVER = 1;
|
||||||
|
|
||||||
|
function isAlreadyExistsError(error: unknown): boolean {
|
||||||
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||||
|
return message.includes('already in use') || message.includes('already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureJetStreamResources(input: {
|
||||||
|
jsm: JetStreamManager;
|
||||||
|
whisperTimeoutMs: number;
|
||||||
|
pdfTimeoutMs: number;
|
||||||
|
pdfAttempts: number;
|
||||||
|
jobsMaxBytes: number;
|
||||||
|
eventsMaxBytes: number;
|
||||||
|
natsReplicas: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const streamConfig = {
|
||||||
|
name: JOBS_STREAM_NAME,
|
||||||
|
subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT],
|
||||||
|
retention: RetentionPolicy.Workqueue,
|
||||||
|
storage: StorageType.File,
|
||||||
|
max_bytes: input.jobsMaxBytes,
|
||||||
|
num_replicas: input.natsReplicas,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await input.jsm.streams.add(streamConfig);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAlreadyExistsError(error)) throw error;
|
||||||
|
await input.jsm.streams.update(JOBS_STREAM_NAME, {
|
||||||
|
subjects: streamConfig.subjects,
|
||||||
|
max_bytes: input.jobsMaxBytes,
|
||||||
|
num_replicas: input.natsReplicas,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsStreamConfig = {
|
||||||
|
name: EVENTS_STREAM_NAME,
|
||||||
|
subjects: [OP_EVENTS_SUBJECT_WILDCARD],
|
||||||
|
retention: RetentionPolicy.Limits,
|
||||||
|
storage: StorageType.File,
|
||||||
|
max_bytes: input.eventsMaxBytes,
|
||||||
|
max_age: nanos(COMPUTE_STATE_TTL_MS),
|
||||||
|
num_replicas: input.natsReplicas,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await input.jsm.streams.add(eventsStreamConfig);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAlreadyExistsError(error)) throw error;
|
||||||
|
await input.jsm.streams.update(EVENTS_STREAM_NAME, {
|
||||||
|
subjects: eventsStreamConfig.subjects,
|
||||||
|
max_bytes: input.eventsMaxBytes,
|
||||||
|
max_age: eventsStreamConfig.max_age,
|
||||||
|
num_replicas: input.natsReplicas,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureConsumer = async (name: string, subject: string, ackWaitMs: number, maxDeliver: number) => {
|
||||||
|
const config = {
|
||||||
|
durable_name: name,
|
||||||
|
ack_policy: AckPolicy.Explicit,
|
||||||
|
deliver_policy: DeliverPolicy.All,
|
||||||
|
replay_policy: ReplayPolicy.Instant,
|
||||||
|
filter_subject: subject,
|
||||||
|
ack_wait: nanos(Math.max(ackWaitMs, 1_000)),
|
||||||
|
max_deliver: maxDeliver,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await input.jsm.consumers.add(JOBS_STREAM_NAME, config);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAlreadyExistsError(error)) throw error;
|
||||||
|
await input.jsm.consumers.update(JOBS_STREAM_NAME, name, {
|
||||||
|
filter_subject: subject,
|
||||||
|
ack_wait: config.ack_wait,
|
||||||
|
max_deliver: maxDeliver,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
ensureConsumer(WHISPER_CONSUMER_NAME, WHISPER_JOBS_SUBJECT, input.whisperTimeoutMs + 15_000, WHISPER_MAX_DELIVER),
|
||||||
|
ensureConsumer(LAYOUT_CONSUMER_NAME, LAYOUT_JOBS_SUBJECT, input.pdfTimeoutMs + 15_000, input.pdfAttempts),
|
||||||
|
]);
|
||||||
|
}
|
||||||
127
compute-worker/src/infrastructure/storage.ts
Normal file
127
compute-worker/src/infrastructure/storage.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import {
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { parsedPdfArtifactKey } from '../storage/artifact-addressing';
|
||||||
|
|
||||||
|
export interface ArtifactStorage {
|
||||||
|
readObject(key: string): Promise<ArrayBuffer>;
|
||||||
|
objectExists(key: string): Promise<boolean>;
|
||||||
|
deleteObject(key: string): Promise<void>;
|
||||||
|
putParsedPdf(documentId: string, namespace: string | null, parsed: unknown): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactStorageConfig {
|
||||||
|
bucket: string;
|
||||||
|
prefix: string;
|
||||||
|
client: S3Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyToBuffer(body: unknown): Promise<Buffer> | Buffer {
|
||||||
|
if (!body) return Buffer.alloc(0);
|
||||||
|
if (body instanceof Uint8Array) return Buffer.from(body);
|
||||||
|
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
||||||
|
if (body instanceof ArrayBuffer) return Buffer.from(body);
|
||||||
|
if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) {
|
||||||
|
const maybe = body as { transformToByteArray?: () => Promise<Uint8Array> };
|
||||||
|
if (typeof maybe.transformToByteArray === 'function') {
|
||||||
|
return maybe.transformToByteArray().then((bytes) => Buffer.from(bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof body === 'object' && body !== null && 'on' in body) {
|
||||||
|
return (async () => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of body as NodeJS.ReadableStream) {
|
||||||
|
if (Buffer.isBuffer(chunk)) chunks.push(chunk);
|
||||||
|
else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk));
|
||||||
|
else chunks.push(Buffer.from(chunk as Uint8Array));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
throw new Error('Unsupported S3 response body type');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
const copy = new Uint8Array(bytes.byteLength);
|
||||||
|
copy.set(bytes);
|
||||||
|
return copy.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotFound(error: unknown): boolean {
|
||||||
|
const maybe = error as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } };
|
||||||
|
return maybe.$metadata?.httpStatusCode === 404
|
||||||
|
|| maybe.name === 'NotFound'
|
||||||
|
|| maybe.name === 'NoSuchKey'
|
||||||
|
|| maybe.Code === 'NotFound'
|
||||||
|
|| maybe.Code === 'NoSuchKey';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeS3Prefix(prefix: string | undefined): string {
|
||||||
|
const value = (prefix || 'openreader').trim();
|
||||||
|
return value ? value.replace(/^\/+|\/+$/g, '') : 'openreader';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createS3ClientFromEnv(requireEnv: (name: string) => string): S3Client {
|
||||||
|
return new S3Client({
|
||||||
|
region: requireEnv('S3_REGION'),
|
||||||
|
endpoint: process.env.S3_ENDPOINT?.trim() || undefined,
|
||||||
|
forcePathStyle: ['1', 'true', 'yes', 'on'].includes(process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase() ?? ''),
|
||||||
|
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||||
|
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: requireEnv('S3_ACCESS_KEY_ID'),
|
||||||
|
secretAccessKey: requireEnv('S3_SECRET_ACCESS_KEY'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createArtifactStorage(config: ArtifactStorageConfig): ArtifactStorage {
|
||||||
|
const safeKey = (key: string): string => {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (!trimmed.startsWith(`${config.prefix}/`)) throw new Error('Object key prefix mismatch');
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async readObject(key) {
|
||||||
|
const response = await config.client.send(new GetObjectCommand({
|
||||||
|
Bucket: config.bucket,
|
||||||
|
Key: safeKey(key),
|
||||||
|
}));
|
||||||
|
return toArrayBuffer(new Uint8Array(await bodyToBuffer(response.Body)));
|
||||||
|
},
|
||||||
|
async objectExists(key) {
|
||||||
|
try {
|
||||||
|
await config.client.send(new HeadObjectCommand({
|
||||||
|
Bucket: config.bucket,
|
||||||
|
Key: safeKey(key),
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotFound(error)) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteObject(key) {
|
||||||
|
await config.client.send(new DeleteObjectCommand({
|
||||||
|
Bucket: config.bucket,
|
||||||
|
Key: safeKey(key),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async putParsedPdf(documentId, namespace, parsed) {
|
||||||
|
const key = parsedPdfArtifactKey({ documentId, namespace, prefix: config.prefix });
|
||||||
|
await config.client.send(new PutObjectCommand({
|
||||||
|
Bucket: config.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: Buffer.from(JSON.stringify(parsed)),
|
||||||
|
ContentType: 'application/json',
|
||||||
|
ServerSideEncryption: 'AES256',
|
||||||
|
}));
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
126
compute-worker/src/jobs/handlers.ts
Normal file
126
compute-worker/src/jobs/handlers.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
runPdfLayoutFromPdfBuffer,
|
||||||
|
runWhisperAlignmentFromAudioBuffer,
|
||||||
|
} from '../inference/local-runtime';
|
||||||
|
import { withIdleTimeoutAndHardCap, withTimeout } from '../inference';
|
||||||
|
import type {
|
||||||
|
PdfLayoutJobRequest,
|
||||||
|
PdfLayoutJobResult,
|
||||||
|
PdfLayoutProgress,
|
||||||
|
WhisperAlignJobRequest,
|
||||||
|
WhisperAlignJobResult,
|
||||||
|
} from '../api/contracts';
|
||||||
|
import type { ArtifactStorage } from '../infrastructure/storage';
|
||||||
|
import { persistParsedPdfWhileSourceExists } from './pdf-artifact-persistence';
|
||||||
|
import { buildInferProgressForPageParsed, buildInferProgressForPageStart } from './pdf-progress';
|
||||||
|
|
||||||
|
const whisperRequestSchema = z.object({
|
||||||
|
text: z.string().trim().min(1),
|
||||||
|
lang: z.string().trim().min(1).max(16).optional(),
|
||||||
|
cacheKey: z.string().trim().min(1).max(256).optional(),
|
||||||
|
audioObjectKey: z.string().trim().min(1).max(2048),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfRequestSchema = z.object({
|
||||||
|
documentId: z.string().trim().min(1),
|
||||||
|
namespace: z.string().trim().min(1).max(128).nullable(),
|
||||||
|
documentObjectKey: z.string().trim().min(1).max(2048),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface JobHandlers {
|
||||||
|
runWhisper(payload: WhisperAlignJobRequest, queueWaitMs: number): Promise<WhisperAlignJobResult>;
|
||||||
|
runPdfLayout(
|
||||||
|
payload: PdfLayoutJobRequest,
|
||||||
|
queueWaitMs: number,
|
||||||
|
hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise<void> },
|
||||||
|
): Promise<PdfLayoutJobResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createJobHandlers(input: {
|
||||||
|
storage: ArtifactStorage;
|
||||||
|
whisperTimeoutMs: number;
|
||||||
|
pdfTimeoutMs: number;
|
||||||
|
pdfHardCapMs: number;
|
||||||
|
}): JobHandlers {
|
||||||
|
return {
|
||||||
|
async runWhisper(payload, queueWaitMs) {
|
||||||
|
const parsed = whisperRequestSchema.parse(payload);
|
||||||
|
const s3FetchStartedAt = Date.now();
|
||||||
|
const audioBuffer = await withTimeout(
|
||||||
|
input.storage.readObject(parsed.audioObjectKey),
|
||||||
|
input.whisperTimeoutMs,
|
||||||
|
'whisper s3 fetch',
|
||||||
|
);
|
||||||
|
const s3FetchMs = Date.now() - s3FetchStartedAt;
|
||||||
|
const computeStartedAt = Date.now();
|
||||||
|
const result = await withTimeout(
|
||||||
|
runWhisperAlignmentFromAudioBuffer({
|
||||||
|
audioBuffer,
|
||||||
|
text: parsed.text,
|
||||||
|
cacheKey: parsed.cacheKey,
|
||||||
|
lang: parsed.lang,
|
||||||
|
}),
|
||||||
|
input.whisperTimeoutMs,
|
||||||
|
'whisper alignment job',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
timing: { queueWaitMs, s3FetchMs, computeMs: Date.now() - computeStartedAt },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async runPdfLayout(payload, queueWaitMs, hooks) {
|
||||||
|
const parsed = pdfRequestSchema.parse(payload);
|
||||||
|
const s3FetchStartedAt = Date.now();
|
||||||
|
const pdfBytes = await withTimeout(
|
||||||
|
input.storage.readObject(parsed.documentObjectKey),
|
||||||
|
Math.max(input.pdfTimeoutMs, 1_000),
|
||||||
|
'pdf s3 fetch',
|
||||||
|
);
|
||||||
|
const s3FetchMs = Date.now() - s3FetchStartedAt;
|
||||||
|
let lastTotalPages = 0;
|
||||||
|
let lastPagesParsed = 0;
|
||||||
|
const computeStartedAt = Date.now();
|
||||||
|
const result = await withIdleTimeoutAndHardCap({
|
||||||
|
idleTimeoutMs: Math.max(input.pdfTimeoutMs, 1_000),
|
||||||
|
hardCapMs: input.pdfHardCapMs,
|
||||||
|
label: 'pdf layout job',
|
||||||
|
run: async (touchProgress) => runPdfLayoutFromPdfBuffer({
|
||||||
|
documentId: parsed.documentId,
|
||||||
|
pdfBytes,
|
||||||
|
onPageStarted: async ({ pageNumber, totalPages }) => {
|
||||||
|
touchProgress();
|
||||||
|
lastTotalPages = totalPages;
|
||||||
|
await hooks?.onProgress?.(buildInferProgressForPageStart({ pageNumber, totalPages }));
|
||||||
|
},
|
||||||
|
onPageParsed: async ({ pageNumber, totalPages }) => {
|
||||||
|
touchProgress();
|
||||||
|
lastTotalPages = totalPages;
|
||||||
|
lastPagesParsed = pageNumber;
|
||||||
|
await hooks?.onProgress?.(buildInferProgressForPageParsed({ pageNumber, totalPages }));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const computeMs = Date.now() - computeStartedAt;
|
||||||
|
if (hooks?.onProgress && lastTotalPages > 0) {
|
||||||
|
await hooks.onProgress({
|
||||||
|
totalPages: lastTotalPages,
|
||||||
|
pagesParsed: lastPagesParsed,
|
||||||
|
currentPage: lastPagesParsed || undefined,
|
||||||
|
phase: 'merge',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const parsedObjectKey = await persistParsedPdfWhileSourceExists({
|
||||||
|
sourceObjectKey: parsed.documentObjectKey,
|
||||||
|
sourceExists: input.storage.objectExists,
|
||||||
|
putParsedObject: () => input.storage.putParsedPdf(parsed.documentId, parsed.namespace, result.parsed),
|
||||||
|
deleteParsedObject: input.storage.deleteObject,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
parsedObjectKey,
|
||||||
|
timing: { queueWaitMs, s3FetchMs, computeMs },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { PdfLayoutProgress } from './compute/api-contracts';
|
import type { PdfLayoutProgress } from '../api/contracts';
|
||||||
|
|
||||||
export function buildInferProgressForPageStart(input: {
|
export function buildInferProgressForPageStart(input: {
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { WorkerOperationKind } from './compute/api-contracts';
|
import type { WorkerOperationKind } from '../api/contracts';
|
||||||
|
|
||||||
export type RetryAction = 'nak_retry' | 'term_fail';
|
export type RetryAction = 'nak_retry' | 'term_fail';
|
||||||
|
|
||||||
312
compute-worker/src/jobs/worker-loop.ts
Normal file
312
compute-worker/src/jobs/worker-loop.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
import type { Consumer, JsMsg } from '@nats-io/jetstream';
|
||||||
|
import type {
|
||||||
|
PdfLayoutJobRequest,
|
||||||
|
PdfLayoutJobResult,
|
||||||
|
PdfLayoutProgress,
|
||||||
|
WhisperAlignJobRequest,
|
||||||
|
WhisperAlignJobResult,
|
||||||
|
WorkerJobTiming,
|
||||||
|
WorkerOperationKind,
|
||||||
|
} from '../api/contracts';
|
||||||
|
import type { JsonCodec } from '../infrastructure/json-codec';
|
||||||
|
import type { JobHandlers } from './handlers';
|
||||||
|
import { buildQueueWaitTiming, decideRetryAction } from './worker-loop-policy';
|
||||||
|
|
||||||
|
const LOOP_ERROR_BACKOFF_MS = 500;
|
||||||
|
const RUNNING_HEARTBEAT_MS = 5000;
|
||||||
|
const PULL_EXPIRES_MS = 5_000;
|
||||||
|
const WHISPER_MAX_DELIVER = 1;
|
||||||
|
const SLOW_JOB_LOG_THRESHOLD_MS_BY_KIND: Record<WorkerOperationKind, number> = {
|
||||||
|
whisper_align: 15_000,
|
||||||
|
pdf_layout: 120_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface QueuedJob<TPayload> {
|
||||||
|
jobId: string;
|
||||||
|
opId: string;
|
||||||
|
opKey: string;
|
||||||
|
kind: WorkerOperationKind;
|
||||||
|
queuedAt: number;
|
||||||
|
payload: TPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerLoopOrchestrator {
|
||||||
|
markRunning(input: { opId: string; startedAt?: number; updatedAt?: number; timing?: WorkerJobTiming }): Promise<unknown>;
|
||||||
|
markProgress(input: {
|
||||||
|
opId: string;
|
||||||
|
progress: PdfLayoutProgress;
|
||||||
|
updatedAt?: number;
|
||||||
|
timing?: WorkerJobTiming;
|
||||||
|
}): Promise<unknown>;
|
||||||
|
markSucceeded(input: { opId: string; result: unknown; updatedAt?: number; timing?: WorkerJobTiming }): Promise<unknown>;
|
||||||
|
markFailed(input: {
|
||||||
|
opId: string;
|
||||||
|
error: { message: string; code?: string } | string;
|
||||||
|
updatedAt?: number;
|
||||||
|
timing?: WorkerJobTiming;
|
||||||
|
}): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerLogger {
|
||||||
|
info(data: unknown, message?: string): void;
|
||||||
|
warn(data: unknown, message?: string): void;
|
||||||
|
error(data: unknown, message?: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConcurrencyGate {
|
||||||
|
private inFlight = 0;
|
||||||
|
private readonly queue: Array<() => void> = [];
|
||||||
|
|
||||||
|
constructor(private readonly limit: number) {}
|
||||||
|
|
||||||
|
async acquire(): Promise<void> {
|
||||||
|
if (this.inFlight < this.limit) {
|
||||||
|
this.inFlight += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.queue.push(() => {
|
||||||
|
this.inFlight += 1;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
release(): void {
|
||||||
|
this.inFlight = Math.max(0, this.inFlight - 1);
|
||||||
|
this.queue.shift()?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error && error.message ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDurationMs(start: number, end: number): number {
|
||||||
|
return Math.max(0, Math.floor(end - start));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTiming(result: unknown): WorkerJobTiming | undefined {
|
||||||
|
if (!result || typeof result !== 'object' || !('timing' in result)) return undefined;
|
||||||
|
return (result as { timing?: WorkerJobTiming }).timing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractResultRef(kind: WorkerOperationKind, result: unknown): string | undefined {
|
||||||
|
if (kind !== 'pdf_layout' || !result || typeof result !== 'object') return undefined;
|
||||||
|
const maybe = result as { parsedObjectKey?: unknown };
|
||||||
|
return typeof maybe.parsedObjectKey === 'string' ? maybe.parsedObjectKey : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkerLoopController(input: {
|
||||||
|
orchestrator: WorkerLoopOrchestrator;
|
||||||
|
handlers: JobHandlers;
|
||||||
|
logger: WorkerLogger;
|
||||||
|
jobConcurrency: number;
|
||||||
|
pdfAttempts: number;
|
||||||
|
whisperCodec: JsonCodec<QueuedJob<WhisperAlignJobRequest>>;
|
||||||
|
pdfCodec: JsonCodec<QueuedJob<PdfLayoutJobRequest>>;
|
||||||
|
isOwnerActive: (owner: object) => boolean;
|
||||||
|
isStopping: () => boolean;
|
||||||
|
markActivity: (reason: string) => void;
|
||||||
|
onInFlightJobsChanged: (delta: number) => void;
|
||||||
|
}) {
|
||||||
|
const gate = new ConcurrencyGate(Math.max(1, Math.floor(input.jobConcurrency)));
|
||||||
|
let loops: Promise<void>[] = [];
|
||||||
|
let stopRequested = false;
|
||||||
|
|
||||||
|
type Context<TPayload> = {
|
||||||
|
decoded: QueuedJob<TPayload>;
|
||||||
|
workerLabel: string;
|
||||||
|
startedAt: number;
|
||||||
|
queueWaitTiming?: { queueWaitMs: number };
|
||||||
|
latestProgress?: PdfLayoutProgress;
|
||||||
|
};
|
||||||
|
|
||||||
|
const markRunning = async <TPayload>(context: Context<TPayload>, updatedAt: number): Promise<void> => {
|
||||||
|
if (context.latestProgress) {
|
||||||
|
await input.orchestrator.markProgress({
|
||||||
|
opId: context.decoded.opId,
|
||||||
|
progress: context.latestProgress,
|
||||||
|
updatedAt,
|
||||||
|
...(context.queueWaitTiming ? { timing: context.queueWaitTiming } : {}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await input.orchestrator.markRunning({
|
||||||
|
opId: context.decoded.opId,
|
||||||
|
startedAt: context.startedAt,
|
||||||
|
updatedAt,
|
||||||
|
...(context.queueWaitTiming ? { timing: context.queueWaitTiming } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const processMessage = async <TPayload, TResult>(work: {
|
||||||
|
msg: JsMsg;
|
||||||
|
codec: JsonCodec<QueuedJob<TPayload>>;
|
||||||
|
run: JobHandlers['runWhisper'] | JobHandlers['runPdfLayout'];
|
||||||
|
workerLabel: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
let context: Context<TPayload> | null = null;
|
||||||
|
let heartbeat: NodeJS.Timeout | null = null;
|
||||||
|
try {
|
||||||
|
const decoded = work.codec.decode(work.msg.data);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
context = {
|
||||||
|
decoded,
|
||||||
|
workerLabel: work.workerLabel,
|
||||||
|
startedAt,
|
||||||
|
queueWaitTiming: buildQueueWaitTiming(decoded.queuedAt, startedAt),
|
||||||
|
};
|
||||||
|
await markRunning(context, startedAt);
|
||||||
|
input.logger.info({
|
||||||
|
worker: work.workerLabel,
|
||||||
|
kind: decoded.kind,
|
||||||
|
opId: decoded.opId,
|
||||||
|
jobId: decoded.jobId,
|
||||||
|
queueWaitMs: context.queueWaitTiming?.queueWaitMs ?? null,
|
||||||
|
deliveryCount: work.msg.info.deliveryCount,
|
||||||
|
}, 'job.started');
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
void markRunning(context!, Date.now()).catch((error) => {
|
||||||
|
input.logger.error({
|
||||||
|
worker: work.workerLabel,
|
||||||
|
opId: context?.decoded.opId,
|
||||||
|
jobId: context?.decoded.jobId,
|
||||||
|
error: toErrorMessage(error),
|
||||||
|
}, 'failed to persist operation heartbeat state');
|
||||||
|
});
|
||||||
|
}, RUNNING_HEARTBEAT_MS);
|
||||||
|
const result = await work.run(decoded.payload as never, context.queueWaitTiming?.queueWaitMs ?? 0, {
|
||||||
|
onProgress: async (progress) => {
|
||||||
|
try {
|
||||||
|
work.msg.working();
|
||||||
|
} catch (error) {
|
||||||
|
input.logger.warn({
|
||||||
|
worker: work.workerLabel,
|
||||||
|
kind: context?.decoded.kind,
|
||||||
|
opId: context?.decoded.opId,
|
||||||
|
jobId: context?.decoded.jobId,
|
||||||
|
error: toErrorMessage(error),
|
||||||
|
}, 'failed to extend JetStream ack wait on progress');
|
||||||
|
}
|
||||||
|
context!.latestProgress = progress;
|
||||||
|
await markRunning(context!, Date.now());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const timing = extractTiming(result);
|
||||||
|
const now = Date.now();
|
||||||
|
await input.orchestrator.markSucceeded({
|
||||||
|
opId: decoded.opId,
|
||||||
|
result,
|
||||||
|
updatedAt: now,
|
||||||
|
...(timing ? { timing } : {}),
|
||||||
|
});
|
||||||
|
work.msg.ack();
|
||||||
|
const durationMs = safeDurationMs(startedAt, now);
|
||||||
|
if (durationMs >= SLOW_JOB_LOG_THRESHOLD_MS_BY_KIND[decoded.kind]) {
|
||||||
|
input.logger.info({ worker: work.workerLabel, kind: decoded.kind, opId: decoded.opId, jobId: decoded.jobId, durationMs, timing: timing ?? null }, 'job.stage');
|
||||||
|
}
|
||||||
|
input.logger.info({
|
||||||
|
worker: work.workerLabel,
|
||||||
|
kind: decoded.kind,
|
||||||
|
opId: decoded.opId,
|
||||||
|
jobId: decoded.jobId,
|
||||||
|
status: 'succeeded',
|
||||||
|
durationMs,
|
||||||
|
resultRef: extractResultRef(decoded.kind, result),
|
||||||
|
timing: timing ?? null,
|
||||||
|
}, 'job.terminal');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = toErrorMessage(error);
|
||||||
|
const deliveryCount = work.msg.info.deliveryCount;
|
||||||
|
const kind = context?.decoded.kind ?? 'pdf_layout';
|
||||||
|
const action = decideRetryAction({ kind, deliveryCount, pdfAttempts: input.pdfAttempts, whisperMaxDeliver: WHISPER_MAX_DELIVER });
|
||||||
|
const timing = context ? buildQueueWaitTiming(context.decoded.queuedAt, Date.now()) : undefined;
|
||||||
|
if (context) {
|
||||||
|
const update = action === 'nak_retry'
|
||||||
|
? markRunning(context, Date.now())
|
||||||
|
: input.orchestrator.markFailed({
|
||||||
|
opId: context.decoded.opId,
|
||||||
|
error: { message: errorMessage },
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
...(timing ? { timing } : {}),
|
||||||
|
});
|
||||||
|
await update.catch((stateError) => input.logger.error({
|
||||||
|
worker: context?.workerLabel,
|
||||||
|
opId: context?.decoded.opId,
|
||||||
|
jobId: context?.decoded.jobId,
|
||||||
|
error: toErrorMessage(stateError),
|
||||||
|
}, 'failed to persist operation state'));
|
||||||
|
}
|
||||||
|
if (action === 'nak_retry') work.msg.nak();
|
||||||
|
else work.msg.term(errorMessage);
|
||||||
|
input.logger.error({
|
||||||
|
worker: context?.workerLabel,
|
||||||
|
kind: context?.decoded.kind,
|
||||||
|
opId: context?.decoded.opId,
|
||||||
|
jobId: context?.decoded.jobId,
|
||||||
|
status: action === 'nak_retry' ? 'running' : 'failed',
|
||||||
|
error: errorMessage,
|
||||||
|
deliveryCount,
|
||||||
|
retryAction: action === 'nak_retry' ? 'nack_retry' : 'term',
|
||||||
|
}, 'job.terminal');
|
||||||
|
} finally {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runLoop = async <TPayload>(work: {
|
||||||
|
owner: object;
|
||||||
|
consumer: Consumer;
|
||||||
|
codec: JsonCodec<QueuedJob<TPayload>>;
|
||||||
|
run: JobHandlers['runWhisper'] | JobHandlers['runPdfLayout'];
|
||||||
|
workerLabel: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const detached = () => input.isStopping() || stopRequested || !input.isOwnerActive(work.owner);
|
||||||
|
while (!detached()) {
|
||||||
|
let msg: JsMsg | null = null;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
msg = await work.consumer.next({ expires: PULL_EXPIRES_MS });
|
||||||
|
} catch (error) {
|
||||||
|
if (detached()) return;
|
||||||
|
input.logger.error({ error: toErrorMessage(error), worker: work.workerLabel }, 'worker pull failed');
|
||||||
|
await sleep(LOOP_ERROR_BACKOFF_MS);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!msg) continue;
|
||||||
|
input.markActivity(`job_received:${work.workerLabel}`);
|
||||||
|
input.onInFlightJobsChanged(1);
|
||||||
|
await gate.acquire();
|
||||||
|
if (detached()) return;
|
||||||
|
await processMessage({ ...work, msg });
|
||||||
|
} finally {
|
||||||
|
if (msg) {
|
||||||
|
gate.release();
|
||||||
|
input.onInFlightJobsChanged(-1);
|
||||||
|
input.markActivity(`job_completed:${work.workerLabel}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
start(owner: object, consumers: { whisper: Consumer; pdfLayout: Consumer }): void {
|
||||||
|
stopRequested = false;
|
||||||
|
loops = [];
|
||||||
|
for (let i = 0; i < input.jobConcurrency; i += 1) {
|
||||||
|
loops.push(runLoop({ owner, consumer: consumers.whisper, codec: input.whisperCodec, run: input.handlers.runWhisper, workerLabel: `whisper-${i + 1}` }));
|
||||||
|
loops.push(runLoop({ owner, consumer: consumers.pdfLayout, codec: input.pdfCodec, run: input.handlers.runPdfLayout, workerLabel: `layout-${i + 1}` }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
stopRequested = true;
|
||||||
|
await Promise.allSettled(loops);
|
||||||
|
loops = [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './state-machine';
|
export * from './state-machine';
|
||||||
export * from './orchestrator';
|
export * from './service';
|
||||||
export * from './sse';
|
export * from './sse';
|
||||||
|
|
@ -4,7 +4,7 @@ import type {
|
||||||
WorkerJobTiming,
|
WorkerJobTiming,
|
||||||
WorkerJobState,
|
WorkerJobState,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
} from './compute/api-contracts';
|
} from '../api/contracts';
|
||||||
|
|
||||||
export type StreamedOperationState = WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>;
|
export type StreamedOperationState = WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>;
|
||||||
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type {
|
||||||
WorkerOperationKind,
|
WorkerOperationKind,
|
||||||
WorkerOperationRequest,
|
WorkerOperationRequest,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
} from '../api-contracts';
|
} from '../api/contracts';
|
||||||
import {
|
import {
|
||||||
buildQueuedState,
|
buildQueuedState,
|
||||||
createErrorShape,
|
createErrorShape,
|
||||||
|
|
@ -4,7 +4,7 @@ import type {
|
||||||
WorkerOperationKind,
|
WorkerOperationKind,
|
||||||
WorkerOperationRequest,
|
WorkerOperationRequest,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
} from '../api-contracts';
|
} from '../api/contracts';
|
||||||
|
|
||||||
export function isTerminalStatus(status: WorkerJobState): boolean {
|
export function isTerminalStatus(status: WorkerJobState): boolean {
|
||||||
return status === 'succeeded' || status === 'failed';
|
return status === 'succeeded' || status === 'failed';
|
||||||
|
|
@ -2,7 +2,7 @@ import type {
|
||||||
WorkerOperationEvent,
|
WorkerOperationEvent,
|
||||||
WorkerOperationKind,
|
WorkerOperationKind,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
} from '../api-contracts';
|
} from '../api/contracts';
|
||||||
|
|
||||||
export type OperationState<Result = unknown> = WorkerOperationState<Result>;
|
export type OperationState<Result = unknown> = WorkerOperationState<Result>;
|
||||||
export type OperationEvent<Result = unknown> = WorkerOperationEvent<Result>;
|
export type OperationEvent<Result = unknown> = WorkerOperationEvent<Result>;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { startComputeWorkerFromEnv } from './runtime';
|
import { startComputeWorkerFromEnv } from './api/app';
|
||||||
|
|
||||||
void startComputeWorkerFromEnv().catch((error) => {
|
void startComputeWorkerFromEnv().catch((error) => {
|
||||||
console.error('[compute-worker] fatal startup error', error);
|
console.error('[compute-worker] fatal startup error', error);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { PDF_PARSER_VERSION } from '../compute/pdf/parser-version';
|
import { PDF_PARSER_VERSION } from '../inference/pdf/parser-version';
|
||||||
import { encodeParserVersion } from '../compute/pdf/parser-version-key';
|
import { encodeParserVersion } from '../inference/pdf/parser-version-key';
|
||||||
|
|
||||||
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
|
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
|
||||||
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
import { createComputeWorkerApp } from '../../src/runtime';
|
import { createComputeWorkerApp } from '../../src/api/app';
|
||||||
import { FakeControlPlane } from '../fixtures/fake-control-plane';
|
import { FakeControlPlane } from '../fixtures/fake-control-plane';
|
||||||
|
|
||||||
const AUTH = { authorization: 'Bearer test-token' };
|
const AUTH = { authorization: 'Bearer test-token' };
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { mergeTextWithRegions } from '../../../src/compute';
|
import { mergeTextWithRegions } from '../../../src/inference';
|
||||||
|
|
||||||
describe('mergeTextWithRegions', () => {
|
describe('mergeTextWithRegions', () => {
|
||||||
test('assigns text items to containing regions by centroid and joins in reading order', () => {
|
test('assigns text items to containing regions by centroid and joins in reading order', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { normalizeTextItemsForLayout } from '../../../src/compute';
|
import { normalizeTextItemsForLayout } from '../../../src/inference';
|
||||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
|
||||||
function makeTextItem(
|
function makeTextItem(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { stitchCrossPageBlocks } from '../../../src/compute';
|
import { stitchCrossPageBlocks } from '../../../src/inference';
|
||||||
import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../../src/compute/types';
|
import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../../src/inference/types';
|
||||||
import { makeParsedPdfDocument, makeParsedPdfPage } from './support/document-fixtures';
|
import { makeParsedPdfDocument, makeParsedPdfPage } from './support/document-fixtures';
|
||||||
|
|
||||||
function makeBlock(
|
function makeBlock(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { BaseDocument } from '../../../src/types/documents';
|
import type { BaseDocument } from '../../../../../src/types/documents';
|
||||||
import type { ParsedPdfBlock, ParsedPdfBlockKind, ParsedPdfDocument, ParsedPdfPage } from '../../../../src/compute/types';
|
import type { ParsedPdfBlock, ParsedPdfBlockKind, ParsedPdfDocument, ParsedPdfPage } from '../../../../src/inference/types';
|
||||||
|
|
||||||
export function makeBaseDocument(overrides: Partial<BaseDocument> = {}): BaseDocument {
|
export function makeBaseDocument(overrides: Partial<BaseDocument> = {}): BaseDocument {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import {
|
import {
|
||||||
mapWordsToSentenceOffsets,
|
mapWordsToSentenceOffsets,
|
||||||
} from '../../../src/compute';
|
} from '../../../src/inference';
|
||||||
|
|
||||||
describe('whisper alignment mapping', () => {
|
describe('whisper alignment mapping', () => {
|
||||||
test('maps words to sentence offsets with punctuation and repeated spaces', () => {
|
test('maps words to sentence offsets with punctuation and repeated spaces', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { buildGoertzelCoefficients, goertzelPower } from '../../../src/compute';
|
import { buildGoertzelCoefficients, goertzelPower } from '../../../src/inference';
|
||||||
|
|
||||||
function dftPower(samples: Float32Array, k: number): number {
|
function dftPower(samples: Float32Array, k: number): number {
|
||||||
const n = samples.length;
|
const n = samples.length;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import * as ort from 'onnxruntime-node';
|
||||||
import {
|
import {
|
||||||
buildWordsFromTimestampedTokens,
|
buildWordsFromTimestampedTokens,
|
||||||
extractTokenStartTimestamps,
|
extractTokenStartTimestamps,
|
||||||
} from '../../../src/compute';
|
} from '../../../src/inference';
|
||||||
|
|
||||||
describe('whisper token timestamp alignment', () => {
|
describe('whisper token timestamp alignment', () => {
|
||||||
test('extracts monotonic token timestamps from cross-attention maps', () => {
|
test('extracts monotonic token timestamps from cross-attention maps', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import type { WorkerOperationRequest } from '../../../src/compute/api-contracts';
|
import type { WorkerOperationRequest } from '../../../src/api/contracts';
|
||||||
import { OperationOrchestrator } from '../../../src/compute/control-plane';
|
import { OperationOrchestrator } from '../../../src/operations';
|
||||||
import {
|
import {
|
||||||
InMemoryOperationEventStream,
|
InMemoryOperationEventStream,
|
||||||
InMemoryOperationQueue,
|
InMemoryOperationQueue,
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,13 @@ vi.mock('@napi-rs/canvas', () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('../../../src/compute/pdf/model', () => ({
|
vi.mock('../../../src/inference/pdf/model', () => ({
|
||||||
ensureModel: vi.fn(async () => '/tmp/model.onnx'),
|
ensureModel: vi.fn(async () => '/tmp/model.onnx'),
|
||||||
MODEL_CONFIG_PATH: '/tmp/model-config.json',
|
MODEL_CONFIG_PATH: '/tmp/model-config.json',
|
||||||
MODEL_PREPROCESSOR_PATH: '/tmp/model-preprocessor.json',
|
MODEL_PREPROCESSOR_PATH: '/tmp/model-preprocessor.json',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../src/compute/config/cpu-budget', () => ({
|
vi.mock('../../../src/inference/config/cpu-budget', () => ({
|
||||||
getOnnxThreadsPerJob: vi.fn(() => 1),
|
getOnnxThreadsPerJob: vi.fn(() => 1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ describe('runLayoutModel', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { runLayoutModel } = await import('../../../src/compute/pdf/runLayoutModel');
|
const { runLayoutModel } = await import('../../../src/inference/pdf/runLayoutModel');
|
||||||
const regions = await runLayoutModel({
|
const regions = await runLayoutModel({
|
||||||
pageWidth: 100,
|
pageWidth: 100,
|
||||||
pageHeight: 100,
|
pageHeight: 100,
|
||||||
|
|
@ -154,7 +154,7 @@ describe('runLayoutModel', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { runLayoutModel } = await import('../../../src/compute/pdf/runLayoutModel');
|
const { runLayoutModel } = await import('../../../src/inference/pdf/runLayoutModel');
|
||||||
const regions = await runLayoutModel({
|
const regions = await runLayoutModel({
|
||||||
pageWidth: 100,
|
pageWidth: 100,
|
||||||
pageHeight: 100,
|
pageHeight: 100,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { encodeSseFrame, parseSseEventId, parseSsePayload } from '../../../src/compute/control-plane/sse';
|
import { encodeSseFrame, parseSseEventId, parseSsePayload } from '../../../src/operations/sse';
|
||||||
|
|
||||||
describe('sse codec', () => {
|
describe('sse codec', () => {
|
||||||
test('encodes event id and payload and decodes both reliably', () => {
|
test('encodes event id and payload and decodes both reliably', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import type { WorkerOperationState } from '../../../src/compute/api-contracts';
|
import type { WorkerOperationState } from '../../../src/api/contracts';
|
||||||
import {
|
import {
|
||||||
explainReplacementReason,
|
explainReplacementReason,
|
||||||
isInflightStatus,
|
isInflightStatus,
|
||||||
isTerminalStatus,
|
isTerminalStatus,
|
||||||
shouldReuseExistingOperation,
|
shouldReuseExistingOperation,
|
||||||
} from '../../../src/compute/control-plane/state-machine';
|
} from '../../../src/operations/state-machine';
|
||||||
|
|
||||||
function runningState(overrides: Partial<WorkerOperationState> = {}): WorkerOperationState {
|
function runningState(overrides: Partial<WorkerOperationState> = {}): WorkerOperationState {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { WorkerOperationKind } from '../../../src/compute/api-contracts';
|
import type { WorkerOperationKind } from '../../../src/api/contracts';
|
||||||
import type {
|
import type {
|
||||||
OperationEvent,
|
OperationEvent,
|
||||||
OperationEventStream,
|
OperationEventStream,
|
||||||
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
OperationState,
|
OperationState,
|
||||||
OperationStateStore,
|
OperationStateStore,
|
||||||
QueuedOperation,
|
QueuedOperation,
|
||||||
} from '../../../src/compute/control-plane/types';
|
} from '../../../src/operations/types';
|
||||||
|
|
||||||
function topicFor(opId: string): string {
|
function topicFor(opId: string): string {
|
||||||
return `op.${opId}`;
|
return `op.${opId}`;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import type {
|
||||||
WorkerOperationEvent,
|
WorkerOperationEvent,
|
||||||
WorkerOperationRequest,
|
WorkerOperationRequest,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
} from '../../src/compute/api-contracts';
|
} from '../../src/api/contracts';
|
||||||
import type { ComputeWorkerRouteDeps } from '../../src/runtime';
|
import type { ComputeWorkerRouteDeps } from '../../src/api/app';
|
||||||
|
|
||||||
type ComputeResult = WhisperAlignJobResult | PdfLayoutJobResult;
|
type ComputeResult = WhisperAlignJobResult | PdfLayoutJobResult;
|
||||||
type ComputeState = WorkerOperationState<ComputeResult>;
|
type ComputeState = WorkerOperationState<ComputeResult>;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { OperationOrchestrator } from '../../src/compute/control-plane';
|
import { OperationOrchestrator } from '../../src/operations';
|
||||||
import type { WorkerOperationRequest } from '../../src/compute/api-contracts';
|
import type { WorkerOperationRequest } from '../../src/api/contracts';
|
||||||
import {
|
import {
|
||||||
JetStreamOperationEventStream,
|
JetStreamOperationEventStream,
|
||||||
JetStreamOperationQueue,
|
JetStreamOperationQueue,
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
opStateKvKey,
|
opStateKvKey,
|
||||||
type KvEntryLike,
|
type KvEntryLike,
|
||||||
type KvStoreLike,
|
type KvStoreLike,
|
||||||
} from '../../src/control-plane/jetstream';
|
} from '../../src/infrastructure/nats-adapters';
|
||||||
|
|
||||||
class FakeKvStore implements KvStoreLike {
|
class FakeKvStore implements KvStoreLike {
|
||||||
private readonly data = new Map<string, KvEntryLike>();
|
private readonly data = new Map<string, KvEntryLike>();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { hashOpKey, opIndexKvKey, opStateKvKey } from '../../src/control-plane/jetstream';
|
import { hashOpKey, opIndexKvKey, opStateKvKey } from '../../src/infrastructure/nats-adapters';
|
||||||
import {
|
import {
|
||||||
buildInferProgressForPageParsed,
|
buildInferProgressForPageParsed,
|
||||||
buildInferProgressForPageStart,
|
buildInferProgressForPageStart,
|
||||||
} from '../../src/pdf-progress';
|
} from '../../src/jobs/pdf-progress';
|
||||||
import { buildPdfOperationKey } from '../../src/api/operation-keys';
|
import { buildPdfOperationKey } from '../../src/api/operation-keys';
|
||||||
import { parsedPdfArtifactKey } from '../../src/storage/artifact-addressing';
|
import { parsedPdfArtifactKey } from '../../src/storage/artifact-addressing';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { recoverOrphanedOperations } from '../../src/orphan-recovery';
|
import { recoverOrphanedOperations } from '../../src/operations/recovery';
|
||||||
import { FakeControlPlane } from '../fixtures/fake-control-plane';
|
import { FakeControlPlane } from '../fixtures/fake-control-plane';
|
||||||
|
|
||||||
describe('orphan recovery', () => {
|
describe('orphan recovery', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { persistParsedPdfWhileSourceExists } from '../../src/pdf-artifact-persistence';
|
import { persistParsedPdfWhileSourceExists } from '../../src/jobs/pdf-artifact-persistence';
|
||||||
|
|
||||||
describe('PDF artifact persistence', () => {
|
describe('PDF artifact persistence', () => {
|
||||||
test('does not write parsed output after the source was deleted', async () => {
|
test('does not write parsed output after the source was deleted', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { buildQueueWaitTiming, decideRetryAction } from '../../src/worker-loop-policy';
|
import { buildQueueWaitTiming, decideRetryAction } from '../../src/jobs/worker-loop-policy';
|
||||||
|
|
||||||
describe('worker loop policy', () => {
|
describe('worker loop policy', () => {
|
||||||
test('returns queue wait timing with non-negative clamped duration', () => {
|
test('returns queue wait timing with non-negative clamped duration', () => {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const forbidden = [
|
||||||
'onnxruntime-node',
|
'onnxruntime-node',
|
||||||
'@huggingface/tokenizers',
|
'@huggingface/tokenizers',
|
||||||
'@openreader/compute-worker',
|
'@openreader/compute-worker',
|
||||||
'/compute-worker/src/compute/',
|
'/compute-worker/src/inference/',
|
||||||
];
|
];
|
||||||
|
|
||||||
const includeExt = new Set(['.js', '.mjs', '.cjs']);
|
const includeExt = new Set(['.js', '.mjs', '.cjs']);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterEach, describe, expect, test } from 'vitest';
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
import { createComputeWorkerApp, type ComputeWorkerApp } from '../../compute-worker/src/runtime';
|
import { createComputeWorkerApp, type ComputeWorkerApp } from '../../compute-worker/src/api/app';
|
||||||
import { FakeControlPlane } from '../../compute-worker/tests/fixtures/fake-control-plane';
|
import { FakeControlPlane } from '../../compute-worker/tests/fixtures/fake-control-plane';
|
||||||
import { ComputeWorkerClient } from '../../src/lib/server/compute-worker/client';
|
import { ComputeWorkerClient } from '../../src/lib/server/compute-worker/client';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue