feat(worker-events): stream op events via jetstream and proxy in app

This commit is contained in:
Richard R 2026-05-25 21:37:27 -06:00
parent 2df49b54de
commit be7ec2c254
6 changed files with 650 additions and 171 deletions

View file

@ -113,3 +113,8 @@ export interface WorkerOperationState<Result = unknown> {
timing?: WorkerJobTiming; timing?: WorkerJobTiming;
progress?: PdfLayoutProgress; progress?: PdfLayoutProgress;
} }
export interface WorkerOperationEvent<Result = unknown> {
eventId: number;
snapshot: WorkerOperationState<Result>;
}

View file

@ -37,6 +37,7 @@ import {
import type { import type {
PdfLayoutJobRequest, PdfLayoutJobRequest,
PdfLayoutJobResult, PdfLayoutJobResult,
WorkerOperationEvent,
WhisperAlignJobRequest, WhisperAlignJobRequest,
WhisperAlignJobResult, WhisperAlignJobResult,
WorkerJobErrorShape, WorkerJobErrorShape,
@ -54,11 +55,12 @@ const WHISPER_JOBS_SUBJECT = 'jobs.whisper';
const LAYOUT_JOBS_SUBJECT = 'jobs.layout'; const LAYOUT_JOBS_SUBJECT = 'jobs.layout';
const WHISPER_CONSUMER_NAME = 'compute_whisper'; const WHISPER_CONSUMER_NAME = 'compute_whisper';
const LAYOUT_CONSUMER_NAME = 'compute_layout'; const LAYOUT_CONSUMER_NAME = 'compute_layout';
const EVENTS_STREAM_NAME = 'compute_events';
const COMPUTE_STATE_BUCKET = 'compute_state'; const COMPUTE_STATE_BUCKET = 'compute_state';
const COMPUTE_STATE_TTL_MS = 24 * 60 * 60 * 1000; const COMPUTE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
const LOOP_ERROR_BACKOFF_MS = 500; const LOOP_ERROR_BACKOFF_MS = 500;
const SSE_POLL_INTERVAL_MS = 400;
const RUNNING_HEARTBEAT_MS = 5000; const RUNNING_HEARTBEAT_MS = 5000;
const OP_EVENTS_SUBJECT_PREFIX = 'ops.events';
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}$/;
const WHISPER_MAX_DELIVER = 1; const WHISPER_MAX_DELIVER = 1;
@ -115,6 +117,8 @@ interface NatsSession {
layoutConsumer: Consumer; layoutConsumer: Consumer;
} }
type StreamedOperationState = WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>;
type JsonCodec<T> = { type JsonCodec<T> = {
encode(value: T): Uint8Array; encode(value: T): Uint8Array;
decode(data: Uint8Array): T; decode(data: Uint8Array): T;
@ -363,6 +367,10 @@ function jobStateKvKey(jobId: string): string {
return `job_state.${jobId}`; return `job_state.${jobId}`;
} }
function opEventsSubject(opId: string): string {
return `${OP_EVENTS_SUBJECT_PREFIX}.${opId}`;
}
function extractResultRef(kind: WorkerOperationKind, result: unknown): string | undefined { function extractResultRef(kind: WorkerOperationKind, result: unknown): string | undefined {
if (kind !== 'pdf_layout' || !result || typeof result !== 'object') return undefined; if (kind !== 'pdf_layout' || !result || typeof result !== 'object') return undefined;
const maybe = result as { parsedObjectKey?: unknown }; const maybe = result as { parsedObjectKey?: unknown };
@ -400,7 +408,8 @@ async function ensureJetStreamResources(
whisperTimeoutMs: number, whisperTimeoutMs: number,
pdfTimeoutMs: number, pdfTimeoutMs: number,
pdfAttempts: number, pdfAttempts: number,
maxBytes: number, jobsMaxBytes: number,
eventsMaxBytes: number,
natsReplicas: number, natsReplicas: number,
): Promise<void> { ): Promise<void> {
const streamConfig = { const streamConfig = {
@ -408,7 +417,7 @@ async function ensureJetStreamResources(
subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT], subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT],
retention: RetentionPolicy.Workqueue, retention: RetentionPolicy.Workqueue,
storage: StorageType.File, storage: StorageType.File,
max_bytes: maxBytes, max_bytes: jobsMaxBytes,
num_replicas: natsReplicas, num_replicas: natsReplicas,
}; };
@ -418,7 +427,29 @@ async function ensureJetStreamResources(
if (!isAlreadyExistsError(error)) throw error; if (!isAlreadyExistsError(error)) throw error;
await jsm.streams.update(JOBS_STREAM_NAME, { await jsm.streams.update(JOBS_STREAM_NAME, {
subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT], subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT],
max_bytes: maxBytes, max_bytes: jobsMaxBytes,
num_replicas: natsReplicas,
});
}
const eventsStreamConfig = {
name: EVENTS_STREAM_NAME,
subjects: [`${OP_EVENTS_SUBJECT_PREFIX}.*`],
retention: RetentionPolicy.Limits,
storage: StorageType.File,
max_bytes: eventsMaxBytes,
max_age: nanos(COMPUTE_STATE_TTL_MS),
num_replicas: natsReplicas,
};
try {
await jsm.streams.add(eventsStreamConfig);
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
await jsm.streams.update(EVENTS_STREAM_NAME, {
subjects: [`${OP_EVENTS_SUBJECT_PREFIX}.*`],
max_bytes: eventsMaxBytes,
max_age: nanos(COMPUTE_STATE_TTL_MS),
num_replicas: natsReplicas, num_replicas: natsReplicas,
}); });
} }
@ -471,6 +502,7 @@ async function main(): Promise<void> {
const pdfAttempts = readIntEnv('COMPUTE_PDF_JOB_ATTEMPTS', 1); const pdfAttempts = readIntEnv('COMPUTE_PDF_JOB_ATTEMPTS', 1);
const prewarmModels = parseBoolEnv('COMPUTE_PREWARM_MODELS', true); const prewarmModels = parseBoolEnv('COMPUTE_PREWARM_MODELS', true);
const jobsStreamMaxBytes = readIntEnv('COMPUTE_JOBS_STREAM_MAX_BYTES', 256 * 1024 * 1024); const jobsStreamMaxBytes = readIntEnv('COMPUTE_JOBS_STREAM_MAX_BYTES', 256 * 1024 * 1024);
const eventsStreamMaxBytes = readIntEnv('COMPUTE_EVENTS_STREAM_MAX_BYTES', 128 * 1024 * 1024);
const jobStatesMaxBytes = readIntEnv('COMPUTE_JOB_STATES_MAX_BYTES', 64 * 1024 * 1024); const jobStatesMaxBytes = readIntEnv('COMPUTE_JOB_STATES_MAX_BYTES', 64 * 1024 * 1024);
const natsReplicas = normalizeNatsReplicas(readIntEnv('COMPUTE_NATS_REPLICAS', 1)); const natsReplicas = normalizeNatsReplicas(readIntEnv('COMPUTE_NATS_REPLICAS', 1));
const opStaleMs = getComputeOpStaleMs(); const opStaleMs = getComputeOpStaleMs();
@ -555,7 +587,15 @@ async function main(): Promise<void> {
const nc: NatsConnection = await connect(connectOpts); const nc: NatsConnection = await connect(connectOpts);
const js: JetStreamClient = jetstream(nc, { timeout: NATS_API_TIMEOUT_MS }); const js: JetStreamClient = jetstream(nc, { timeout: NATS_API_TIMEOUT_MS });
const jsm: JetStreamManager = await jetstreamManager(nc, { timeout: NATS_API_TIMEOUT_MS }); const jsm: JetStreamManager = await jetstreamManager(nc, { timeout: NATS_API_TIMEOUT_MS });
await ensureJetStreamResources(jsm, whisperTimeoutMs, pdfTimeoutMs, pdfAttempts, jobsStreamMaxBytes, natsReplicas); await ensureJetStreamResources(
jsm,
whisperTimeoutMs,
pdfTimeoutMs,
pdfAttempts,
jobsStreamMaxBytes,
eventsStreamMaxBytes,
natsReplicas,
);
const kv = await new Kvm(js).create(COMPUTE_STATE_BUCKET, { const kv = await new Kvm(js).create(COMPUTE_STATE_BUCKET, {
replicas: natsReplicas, replicas: natsReplicas,
history: 1, history: 1,
@ -638,21 +678,32 @@ async function main(): Promise<void> {
onnxThreadsPerJob: getOnnxThreadsPerJob(), onnxThreadsPerJob: getOnnxThreadsPerJob(),
natsApiTimeoutMs: NATS_API_TIMEOUT_MS, natsApiTimeoutMs: NATS_API_TIMEOUT_MS,
natsReplicas, natsReplicas,
eventsStreamMaxBytes,
pdfLayoutHardCapMs: pdfHardCapMs, pdfLayoutHardCapMs: pdfHardCapMs,
}, 'compute runtime config'); }, 'compute runtime config');
const opIndexCodec = createJsonCodec<OpIndexEntry>(); const opIndexCodec = createJsonCodec<OpIndexEntry>();
const opStateCodec = createJsonCodec<WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>>(); const opStateCodec = createJsonCodec<StreamedOperationState>();
const whisperJobCodec = createJsonCodec<QueuedJob<WhisperAlignJobRequest>>(); const whisperJobCodec = createJsonCodec<QueuedJob<WhisperAlignJobRequest>>();
const layoutJobCodec = createJsonCodec<QueuedJob<PdfLayoutJobRequest>>(); const layoutJobCodec = createJsonCodec<QueuedJob<PdfLayoutJobRequest>>();
const jobStateCodec = createJsonCodec<StoredJobState<WhisperAlignJobResult | PdfLayoutJobResult>>(); const jobStateCodec = createJsonCodec<StoredJobState<WhisperAlignJobResult | PdfLayoutJobResult>>();
const opEventCodec = createJsonCodec<StreamedOperationState>();
const putOpState = async (state: WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>): Promise<void> => { const putOpState = async (state: StreamedOperationState): Promise<void> => {
const { kv } = await ensureConnected(); const { kv, js } = await ensureConnected();
await kv.put(opStateKvKey(state.opId), opStateCodec.encode(state)); await kv.put(opStateKvKey(state.opId), opStateCodec.encode(state));
try {
await js.publish(opEventsSubject(state.opId), opEventCodec.encode(state));
} catch (error) {
app.log.warn({
opId: state.opId,
status: state.status,
error: toErrorMessage(error),
}, 'failed to publish op event');
}
}; };
const getOpState = async (opId: string): Promise<WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult> | null> => { const getOpState = async (opId: string): Promise<StreamedOperationState | null> => {
const { kv } = await ensureConnected(); const { kv } = await ensureConnected();
const entry = await kv.get(opStateKvKey(opId)); const entry = await kv.get(opStateKvKey(opId));
if (!entry || entry.operation !== 'PUT') return null; if (!entry || entry.operation !== 'PUT') return null;
@ -1007,6 +1058,18 @@ async function main(): Promise<void> {
return { error: 'Operation not found' }; return { error: 'Operation not found' };
} }
const cursorQueryRaw = request.query as { sinceEventId?: string | number | null } | undefined;
const cursorFromQuery = Number(cursorQueryRaw?.sinceEventId ?? 0);
const lastEventIdHeader = request.headers['last-event-id'];
const cursorFromHeader = Number(
Array.isArray(lastEventIdHeader) ? (lastEventIdHeader[0] ?? 0) : (lastEventIdHeader ?? 0),
);
const sinceEventId = Math.max(
0,
Number.isFinite(cursorFromQuery) ? Math.floor(cursorFromQuery) : 0,
Number.isFinite(cursorFromHeader) ? Math.floor(cursorFromHeader) : 0,
);
reply.hijack(); reply.hijack();
// onResponse will not fire for a hijacked reply, so release the HTTP in-flight // onResponse will not fire for a hijacked reply, so release the HTTP in-flight
// count here and track the long-lived stream via activeSse instead. // count here and track the long-lived stream via activeSse instead.
@ -1018,12 +1081,18 @@ async function main(): Promise<void> {
reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no'); reply.raw.setHeader('X-Accel-Buffering', 'no');
const writeSnapshot = (snapshot: WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>): void => { const writeSnapshot = (snapshot: StreamedOperationState, eventId: number): void => {
if (closed || reply.raw.writableEnded) return; if (closed || reply.raw.writableEnded) return;
reply.raw.write(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`); const frameEvent: WorkerOperationEvent<WhisperAlignJobResult | PdfLayoutJobResult> = {
eventId,
snapshot,
};
reply.raw.write(`id: ${eventId}\nevent: snapshot\ndata: ${JSON.stringify(frameEvent)}\n\n`);
}; };
let closed = false; let closed = false;
let consumerName: string | null = null;
let messages: Awaited<ReturnType<Consumer['consume']>> | null = null;
request.raw.on('close', () => { request.raw.on('close', () => {
closed = true; closed = true;
activeSse = Math.max(0, activeSse - 1); activeSse = Math.max(0, activeSse - 1);
@ -1032,25 +1101,54 @@ async function main(): Promise<void> {
try { try {
let current = initial; let current = initial;
writeSnapshot(current);
let signature = JSON.stringify(current); let signature = JSON.stringify(current);
writeSnapshot(current, sinceEventId > 0 ? sinceEventId : 0);
if (isTerminalStatus(current.status)) {
return reply;
}
while (!closed && !isTerminalStatus(current.status)) { const { jsm, js } = await ensureConnected();
await sleep(SSE_POLL_INTERVAL_MS); const consumerConfig = {
const next = await getOpState(params.data.opId); name: `op_events_${params.data.opId.slice(0, 12)}_${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}`,
if (!next) break; ack_policy: AckPolicy.None,
const nextSignature = JSON.stringify(next); deliver_policy: sinceEventId > 0 ? DeliverPolicy.StartSequence : DeliverPolicy.New,
replay_policy: ReplayPolicy.Instant,
filter_subject: opEventsSubject(params.data.opId),
max_deliver: 1,
inactive_threshold: nanos(60_000_000_000),
...(sinceEventId > 0 ? { opt_start_seq: sinceEventId + 1 } : {}),
};
const info = await jsm.consumers.add(EVENTS_STREAM_NAME, consumerConfig);
consumerName = info.name;
const consumer = await js.consumers.get(EVENTS_STREAM_NAME, info.name);
messages = await consumer.consume();
for await (const msg of messages) {
if (closed) break;
const state = opEventCodec.decode(msg.data);
if (state.opId !== params.data.opId) continue;
const nextSignature = JSON.stringify(state);
if (nextSignature !== signature) { if (nextSignature !== signature) {
current = next; current = state;
signature = nextSignature; signature = nextSignature;
writeSnapshot(current); writeSnapshot(current, msg.seq);
} }
if (isTerminalStatus(state.status)) break;
} }
} catch (error) { } catch (error) {
app.log.warn({ app.log.warn({
opId: params.data.opId, opId: params.data.opId,
error: toErrorMessage(error), error: toErrorMessage(error),
}, 'op events stream loop error'); }, 'op events stream loop error');
} finally {
if (messages) {
await messages.close().catch(() => undefined);
}
if (consumerName) {
const { jsm } = await ensureConnected();
await jsm.consumers.delete(EVENTS_STREAM_NAME, consumerName).catch(() => undefined);
}
} }
if (!reply.raw.writableEnded) { if (!reply.raw.writableEnded) {

View file

@ -4,6 +4,7 @@ import { db } from '@/db';
import { documents } from '@/db/schema'; import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth'; import { requireAuthContext } from '@/lib/server/auth/auth';
import { readComputeMode } from '@/lib/server/compute/mode'; import { readComputeMode } from '@/lib/server/compute/mode';
import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker';
import { getParseProgressBus } from '@/lib/server/documents/parse-progress-bus'; import { getParseProgressBus } from '@/lib/server/documents/parse-progress-bus';
import { hashUserId } from '@/lib/server/documents/parse-progress-events'; import { hashUserId } from '@/lib/server/documents/parse-progress-events';
import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { isValidDocumentId } from '@/lib/server/documents/blobstore';
@ -12,6 +13,7 @@ import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3'; import { isS3Configured } from '@/lib/server/storage/s3';
import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf';
import type { PdfLayoutJobResult, WorkerOperationEvent, WorkerOperationState } from '@openreader/compute-core/api-contracts';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@ -31,6 +33,11 @@ type ParsedSnapshot = {
parseProgress: PdfParseProgress | null; parseProgress: PdfParseProgress | null;
}; };
type SnapshotState = {
snapshot: ParsedSnapshot;
opId: string | null;
};
function s3NotConfiguredResponse(): NextResponse { function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json( return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' }, { error: 'Documents storage is not configured. Set S3_* environment variables.' },
@ -42,7 +49,53 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
async function toSnapshot(row: ParseRow): Promise<ParsedSnapshot> { function parseSsePayload(frame: string): string | null {
const lines = frame.replace(/\r\n/g, '\n').split('\n');
const dataLines: string[] = [];
for (const line of lines) {
if (!line.startsWith('data:')) continue;
dataLines.push(line.slice('data:'.length).trimStart());
}
if (dataLines.length === 0) return null;
return dataLines.join('\n');
}
function parseSseEventId(frame: string): number | null {
const lines = frame.replace(/\r\n/g, '\n').split('\n');
for (const line of lines) {
if (!line.startsWith('id:')) continue;
const value = Number(line.slice('id:'.length).trim());
if (Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
}
return null;
}
function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']): PdfParseStatus {
switch (status) {
case 'queued':
return 'pending';
case 'running':
return 'running';
case 'succeeded':
return 'ready';
case 'failed':
return 'failed';
default:
return 'pending';
}
}
function snapshotFromWorkerState(state: WorkerOperationState<PdfLayoutJobResult>): ParsedSnapshot {
const parseStatus = mapWorkerStatusToParseStatus(state.status);
return {
parseStatus,
parseProgress: parseStatus === 'running' ? (state.progress ?? null) : null,
};
}
async function toSnapshotState(row: ParseRow): Promise<SnapshotState> {
const state = await healStaleDocumentParseState({ const state = await healStaleDocumentParseState({
documentId: row.id, documentId: row.id,
userId: row.userId, userId: row.userId,
@ -50,8 +103,11 @@ async function toSnapshot(row: ParseRow): Promise<ParsedSnapshot> {
}); });
const parseStatus = normalizeParseStatus(state.status); const parseStatus = normalizeParseStatus(state.status);
return { return {
parseStatus, snapshot: {
parseProgress: state.progress ?? null, parseStatus,
parseProgress: state.progress ?? null,
},
opId: typeof state.opId === 'string' && state.opId.trim() ? state.opId.trim() : null,
}; };
} }
@ -72,6 +128,33 @@ async function loadPreferredRow(input: {
return rows.find((candidate) => candidate.userId === input.storageUserId) ?? rows[0] ?? null; return rows.find((candidate) => candidate.userId === input.storageUserId) ?? rows[0] ?? null;
} }
async function syncFromDb(input: {
documentId: string;
storageUserId: string;
allowedUserIds: string[];
signature: string;
writeSnapshot: (snapshot: ParsedSnapshot) => void;
}): Promise<{ snapshot: ParsedSnapshot; opId: string | null; signature: string } | null> {
const nextRow = await loadPreferredRow({
documentId: input.documentId,
storageUserId: input.storageUserId,
allowedUserIds: input.allowedUserIds,
});
if (!nextRow) return null;
const nextState = await toSnapshotState(nextRow);
const nextSignature = JSON.stringify(nextState.snapshot);
if (nextSignature !== input.signature) {
input.writeSnapshot(nextState.snapshot);
}
return {
snapshot: nextState.snapshot,
opId: nextState.opId,
signature: nextSignature,
};
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try { try {
if (!isS3Configured()) return s3NotConfiguredResponse(); if (!isS3Configured()) return s3NotConfiguredResponse();
@ -100,10 +183,11 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
} }
const initial = await toSnapshot(row); const initialState = await toSnapshotState(row);
const computeMode = readComputeMode(); const computeMode = readComputeMode();
const bus = computeMode === 'local' ? await getParseProgressBus() : null; const bus = computeMode === 'local' ? await getParseProgressBus() : null;
const workerCfg = computeMode === 'worker' ? getWorkerClientConfigFromEnv() : null;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({ const stream = new ReadableStream<Uint8Array>({
@ -112,6 +196,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
let keepaliveTimer: ReturnType<typeof setInterval> | null = null; let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
let resyncTimer: ReturnType<typeof setInterval> | null = null; let resyncTimer: ReturnType<typeof setInterval> | null = null;
let workerAbort: AbortController | null = null;
const allowedUserHashes = new Set(allowedUserIds.map((candidate) => hashUserId(candidate))); const allowedUserHashes = new Set(allowedUserIds.map((candidate) => hashUserId(candidate)));
@ -134,6 +219,10 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
unsubscribe(); unsubscribe();
unsubscribe = null; unsubscribe = null;
} }
if (workerAbort) {
workerAbort.abort();
workerAbort = null;
}
try { try {
controller.close(); controller.close();
} catch { } catch {
@ -141,29 +230,8 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
} }
}; };
const syncFromDb = async (input: {
signature: string;
}): Promise<{ snapshot: ParsedSnapshot; signature: string } | null> => {
const nextRow = await loadPreferredRow({
documentId: id,
storageUserId,
allowedUserIds,
});
if (!nextRow) return null;
const nextSnapshot = await toSnapshot(nextRow);
const nextSignature = JSON.stringify(nextSnapshot);
if (nextSignature !== input.signature) {
writeSnapshot(nextSnapshot);
}
return {
snapshot: nextSnapshot,
signature: nextSignature,
};
};
const runLocalRealtime = async () => { const runLocalRealtime = async () => {
let current = initial; let current = initialState.snapshot;
let signature = JSON.stringify(current); let signature = JSON.stringify(current);
writeSnapshot(current); writeSnapshot(current);
@ -179,7 +247,13 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
resyncTimer = setInterval(() => { resyncTimer = setInterval(() => {
if (closed) return; if (closed) return;
void syncFromDb({ signature }).then((next) => { void syncFromDb({
documentId: id,
storageUserId,
allowedUserIds,
signature,
writeSnapshot,
}).then((next) => {
if (closed) return; if (closed) return;
if (!next) { if (!next) {
closeStream(); closeStream();
@ -202,7 +276,13 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
documentId: id, documentId: id,
userIdHashes: allowedUserHashes, userIdHashes: allowedUserHashes,
onEvent: async () => { onEvent: async () => {
const next = await syncFromDb({ signature }); const next = await syncFromDb({
documentId: id,
storageUserId,
allowedUserIds,
signature,
writeSnapshot,
});
if (!next) { if (!next) {
closeStream(); closeStream();
return; return;
@ -226,24 +306,204 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
unsubscribe = nextUnsubscribe; unsubscribe = nextUnsubscribe;
}; };
const runWorkerPolling = async () => { const runWorkerProxy = async () => {
let current = initial; if (!workerCfg) throw new Error('Worker mode selected but worker config is unavailable');
writeSnapshot(current);
let current = initialState.snapshot;
let signature = JSON.stringify(current); let signature = JSON.stringify(current);
let currentOpId = initialState.opId;
let lastEventId: number | null = null;
writeSnapshot(current);
if (current.parseStatus === 'ready' || current.parseStatus === 'failed') {
closeStream();
return;
}
keepaliveTimer = setInterval(() => {
if (closed) return;
controller.enqueue(encoder.encode(': keepalive\n\n'));
}, SSE_KEEPALIVE_MS);
resyncTimer = setInterval(() => {
if (closed) return;
void syncFromDb({
documentId: id,
storageUserId,
allowedUserIds,
signature,
writeSnapshot,
}).then((next) => {
if (closed) return;
if (!next) {
closeStream();
return;
}
current = next.snapshot;
signature = next.signature;
if (next.opId !== currentOpId) {
currentOpId = next.opId;
lastEventId = null;
if (workerAbort) {
workerAbort.abort();
workerAbort = null;
}
}
if (current.parseStatus === 'ready' || current.parseStatus === 'failed') {
closeStream();
}
}).catch((error) => {
if (closed) return;
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
});
}, SSE_RESYNC_INTERVAL_MS);
while (!closed) { while (!closed) {
if (current.parseStatus === 'ready' || current.parseStatus === 'failed') break; if (!currentOpId) {
await sleep(SSE_POLL_INTERVAL_MS); await sleep(SSE_POLL_INTERVAL_MS);
if (closed) break; const next = await syncFromDb({
documentId: id,
storageUserId,
allowedUserIds,
signature,
writeSnapshot,
});
if (!next) {
closeStream();
return;
}
current = next.snapshot;
signature = next.signature;
currentOpId = next.opId;
if (current.parseStatus === 'ready' || current.parseStatus === 'failed') {
closeStream();
return;
}
continue;
}
const next = await syncFromDb({ signature }); workerAbort = new AbortController();
if (!next) break; const query = lastEventId && lastEventId > 0
? `?sinceEventId=${encodeURIComponent(String(lastEventId))}`
: '';
const response = await fetch(
`${workerCfg.baseUrl}/ops/${encodeURIComponent(currentOpId)}/events${query}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${workerCfg.token}`,
Accept: 'text/event-stream',
...(lastEventId && lastEventId > 0 ? { 'Last-Event-ID': String(lastEventId) } : {}),
},
cache: 'no-store',
signal: workerAbort.signal,
},
);
if (closed) return;
if (!response.ok) {
const detail = await response.text().catch(() => '');
console.warn('[parsed/events] worker stream request failed', {
documentId: id,
opId: currentOpId,
status: response.status,
detail,
});
await sleep(500);
continue;
}
if (!response.body) {
await sleep(500);
continue;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let streamEnded = false;
while (!closed && !streamEnded) {
const read = await reader.read();
if (read.done) {
streamEnded = true;
break;
}
buffer += decoder.decode(read.value, { stream: true });
while (true) {
const frameEnd = buffer.indexOf('\n\n');
if (frameEnd < 0) break;
const frame = buffer.slice(0, frameEnd);
buffer = buffer.slice(frameEnd + 2);
const eventId = parseSseEventId(frame);
if (eventId && eventId > 0) {
lastEventId = eventId;
}
const payload = parseSsePayload(frame);
if (!payload) continue;
let parsed: WorkerOperationEvent<PdfLayoutJobResult> | WorkerOperationState<PdfLayoutJobResult>;
try {
parsed = JSON.parse(payload) as WorkerOperationEvent<PdfLayoutJobResult> | WorkerOperationState<PdfLayoutJobResult>;
} catch {
continue;
}
const workerSnapshot: WorkerOperationState<PdfLayoutJobResult> = (
parsed && typeof parsed === 'object' && 'snapshot' in parsed
? parsed.snapshot
: parsed as WorkerOperationState<PdfLayoutJobResult>
);
if (!workerSnapshot || workerSnapshot.opId !== currentOpId) continue;
const nextSnapshot = snapshotFromWorkerState(workerSnapshot);
const nextSignature = JSON.stringify(nextSnapshot);
if (nextSignature !== signature) {
current = nextSnapshot;
signature = nextSignature;
writeSnapshot(current);
}
if (current.parseStatus === 'ready' || current.parseStatus === 'failed') {
closeStream();
return;
}
}
}
if (closed) return;
const next = await syncFromDb({
documentId: id,
storageUserId,
allowedUserIds,
signature,
writeSnapshot,
});
if (!next) {
closeStream();
return;
}
current = next.snapshot; current = next.snapshot;
signature = next.signature; signature = next.signature;
if (next.opId !== currentOpId) {
currentOpId = next.opId;
lastEventId = null;
}
if (current.parseStatus === 'ready' || current.parseStatus === 'failed') {
closeStream();
return;
}
await sleep(250);
} }
}; };
const run = computeMode === 'local' ? runLocalRealtime : runWorkerPolling; const run = computeMode === 'local' ? runLocalRealtime : runWorkerProxy;
void run() void run()
.catch((error) => { .catch((error) => {

View file

@ -4,6 +4,7 @@ import type {
ParsedPdfDocument, ParsedPdfDocument,
} from '@openreader/compute-core/types'; } from '@openreader/compute-core/types';
import type { PdfLayoutProgress, WhisperAlignJobBase } from '@openreader/compute-core/api-contracts'; import type { PdfLayoutProgress, WhisperAlignJobBase } from '@openreader/compute-core/api-contracts';
import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts';
export type ComputeMode = 'local' | 'worker'; export type ComputeMode = 'local' | 'worker';
@ -23,6 +24,7 @@ export interface PdfLayoutInput {
pdfBytes?: ArrayBuffer; pdfBytes?: ArrayBuffer;
forceToken?: string; forceToken?: string;
onProgress?: (progress: PdfLayoutProgress) => void | Promise<void>; onProgress?: (progress: PdfLayoutProgress) => void | Promise<void>;
onWorkerSnapshot?: (snapshot: WorkerOperationState<PdfLayoutJobResult>) => void | Promise<void>;
} }
export type PdfLayoutResult = export type PdfLayoutResult =

View file

@ -4,6 +4,7 @@ import { getWorkerClientWaitTimeoutMs } from '@openreader/compute-core';
import type { import type {
PdfLayoutJobRequest, PdfLayoutJobRequest,
PdfLayoutJobResult, PdfLayoutJobResult,
WorkerOperationEvent,
WhisperAlignJobRequest, WhisperAlignJobRequest,
WhisperAlignJobResult, WhisperAlignJobResult,
WorkerOperationState, WorkerOperationState,
@ -118,6 +119,13 @@ function normalizeWorkerBaseUrl(raw: string): string {
return parsed.toString().replace(/\/+$/, ''); return parsed.toString().replace(/\/+$/, '');
} }
export function getWorkerClientConfigFromEnv(): { baseUrl: string; token: string } {
return {
baseUrl: normalizeWorkerBaseUrl(readRequiredEnv('COMPUTE_WORKER_URL')),
token: readRequiredEnv('COMPUTE_WORKER_TOKEN'),
};
}
function parseRetryAfterMs(value: string | null): number | null { function parseRetryAfterMs(value: string | null): number | null {
if (!value) return null; if (!value) return null;
const asNum = Number(value); const asNum = Number(value);
@ -189,6 +197,18 @@ function extractSsePayload(frame: string): string | null {
return dataLines.join('\n'); return dataLines.join('\n');
} }
function extractSseId(frame: string): number | null {
const normalized = frame.replace(/\r\n/g, '\n');
for (const line of normalized.split('\n')) {
if (!line.startsWith('id:')) continue;
const value = Number(line.slice('id:'.length).trim());
if (Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
}
return null;
}
type RetryMeta = { type RetryMeta = {
attempt: number, attempt: number,
maxAttempts: number, maxAttempts: number,
@ -240,8 +260,9 @@ export class WorkerComputeBackend implements ComputeBackend {
private readonly retries: number; private readonly retries: number;
constructor() { constructor() {
this.baseUrl = normalizeWorkerBaseUrl(readRequiredEnv('COMPUTE_WORKER_URL')); const cfg = getWorkerClientConfigFromEnv();
this.token = readRequiredEnv('COMPUTE_WORKER_TOKEN'); this.baseUrl = cfg.baseUrl;
this.token = cfg.token;
this.waitTimeoutMsByKind = { this.waitTimeoutMsByKind = {
whisper_align: getWorkerClientWaitTimeoutMs('whisper_align'), whisper_align: getWorkerClientWaitTimeoutMs('whisper_align'),
pdf_layout: getWorkerClientWaitTimeoutMs('pdf_layout'), pdf_layout: getWorkerClientWaitTimeoutMs('pdf_layout'),
@ -371,6 +392,7 @@ export class WorkerComputeBackend implements ComputeBackend {
documentId: input.documentId, documentId: input.documentId,
attempt, attempt,
}); });
await input.onWorkerSnapshot?.(op);
const final = isTerminalStatus(op.status) const final = isTerminalStatus(op.status)
? op ? op
@ -382,6 +404,7 @@ export class WorkerComputeBackend implements ComputeBackend {
attempt, attempt,
waitTimeoutMs: this.waitTimeoutMsByKind.pdf_layout, waitTimeoutMs: this.waitTimeoutMsByKind.pdf_layout,
onSnapshot: (snapshot) => { onSnapshot: (snapshot) => {
void input.onWorkerSnapshot?.(snapshot);
if (snapshot.progress) { if (snapshot.progress) {
void input.onProgress?.(snapshot.progress); void input.onProgress?.(snapshot.progress);
} }
@ -513,123 +536,150 @@ export class WorkerComputeBackend implements ComputeBackend {
}); });
try { try {
const res = await fetch(`${this.baseUrl}/ops/${encodeURIComponent(opId)}/events`, { let latest: WorkerOperationState<Result> | null = null;
method: 'GET', let lastEventId: number | null = null;
headers: { let eventCount = 0;
Authorization: `Bearer ${this.token}`, let lastStatus: string | null = null;
Accept: 'text/event-stream',
'x-openreader-trace-id': traceId,
},
signal: controller.signal,
});
if (!res.ok) { while (!controller.signal.aborted) {
const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); const query = lastEventId && lastEventId > 0
const detail = await res.text().catch(() => ''); ? `?sinceEventId=${encodeURIComponent(String(lastEventId))}`
logWorker(res.status >= 500 ? 'warn' : 'error', 'sse.wait.http_failed', { : '';
const res = await fetch(`${this.baseUrl}/ops/${encodeURIComponent(opId)}/events${query}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'text/event-stream',
'x-openreader-trace-id': traceId,
...(lastEventId && lastEventId > 0 ? { 'Last-Event-ID': String(lastEventId) } : {}),
},
signal: controller.signal,
});
if (!res.ok) {
const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));
const detail = await res.text().catch(() => '');
logWorker(res.status >= 500 ? 'warn' : 'error', 'sse.wait.http_failed', {
...context,
traceId,
opId,
status: res.status,
retryAfterMs,
durationMs: Date.now() - startedAt,
detail: truncateForLog(detail),
});
throw new WorkerHttpError(
`Worker request failed (GET /ops/${encodeURIComponent(opId)}/events): ${res.status}${detail ? ` ${detail}` : ''}`,
res.status,
retryAfterMs,
);
}
if (!res.body) {
logWorker('error', 'sse.wait.no_body', {
...context,
traceId,
opId,
durationMs: Date.now() - startedAt,
});
throw new Error('Worker operation stream response has no body');
}
logWorker('info', 'sse.wait.connected', {
...context, ...context,
traceId, traceId,
opId, opId,
status: res.status, status: res.status,
retryAfterMs,
durationMs: Date.now() - startedAt,
detail: truncateForLog(detail),
}); });
throw new WorkerHttpError( const reader = res.body.getReader();
`Worker request failed (GET /ops/${encodeURIComponent(opId)}/events): ${res.status}${detail ? ` ${detail}` : ''}`, const decoder = new TextDecoder();
res.status, let buffer = '';
retryAfterMs,
);
}
if (!res.body) {
logWorker('error', 'sse.wait.no_body', {
...context,
traceId,
opId,
durationMs: Date.now() - startedAt,
});
throw new Error('Worker operation stream response has no body');
}
logWorker('info', 'sse.wait.connected', {
...context,
traceId,
opId,
status: res.status,
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let latest: WorkerOperationState<Result> | null = null;
let eventCount = 0;
let lastStatus: string | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) { while (true) {
const frameEnd = buffer.indexOf('\n\n'); const { done, value } = await reader.read();
if (frameEnd < 0) break; if (done) break;
const frame = buffer.slice(0, frameEnd); buffer += decoder.decode(value, { stream: true });
buffer = buffer.slice(frameEnd + 2);
const payload = extractSsePayload(frame); while (true) {
if (!payload) continue; const frameEnd = buffer.indexOf('\n\n');
if (frameEnd < 0) break;
const frame = buffer.slice(0, frameEnd);
buffer = buffer.slice(frameEnd + 2);
let snapshot: WorkerOperationState<Result>; const eventId = extractSseId(frame);
try { if (eventId && eventId > 0) {
snapshot = JSON.parse(payload) as WorkerOperationState<Result>; lastEventId = eventId;
} catch { }
logWorker('warn', 'sse.wait.json_parse_skipped', { const payload = extractSsePayload(frame);
...context, if (!payload) continue;
traceId,
opId,
sample: truncateForLog(payload),
});
continue;
}
eventCount += 1; let event: WorkerOperationEvent<Result> | WorkerOperationState<Result>;
latest = snapshot; try {
context.onSnapshot?.(snapshot); event = JSON.parse(payload) as WorkerOperationEvent<Result> | WorkerOperationState<Result>;
if (snapshot.status !== lastStatus) { } catch {
lastStatus = snapshot.status; logWorker('warn', 'sse.wait.json_parse_skipped', {
logWorker('info', 'sse.wait.status', { ...context,
...context, traceId,
traceId, opId,
opId, sample: truncateForLog(payload),
eventCount, });
status: snapshot.status, continue;
jobId: snapshot.jobId ?? null, }
updatedAt: snapshot.updatedAt ?? null,
}); const snapshot: WorkerOperationState<Result> = (
} event && typeof event === 'object' && 'snapshot' in event
if (isTerminalStatus(snapshot.status)) { ? (event as WorkerOperationEvent<Result>).snapshot
logWorker('info', 'sse.wait.terminal', { : (event as WorkerOperationState<Result>)
...context, );
traceId, if (!snapshot || typeof snapshot !== 'object') continue;
opId, if (snapshot.opId !== opId) continue;
eventCount,
status: snapshot.status, eventCount += 1;
durationMs: Date.now() - startedAt, latest = snapshot;
}); context.onSnapshot?.(snapshot);
return snapshot; if (snapshot.status !== lastStatus) {
lastStatus = snapshot.status;
logWorker('info', 'sse.wait.status', {
...context,
traceId,
opId,
eventCount,
status: snapshot.status,
jobId: snapshot.jobId ?? null,
updatedAt: snapshot.updatedAt ?? null,
});
}
if (isTerminalStatus(snapshot.status)) {
logWorker('info', 'sse.wait.terminal', {
...context,
traceId,
opId,
eventCount,
status: snapshot.status,
durationMs: Date.now() - startedAt,
});
return snapshot;
}
} }
} }
if (latest && isTerminalStatus(latest.status)) {
logWorker('info', 'sse.wait.terminal_after_close', {
...context,
traceId,
opId,
eventCount,
status: latest.status,
durationMs: Date.now() - startedAt,
});
return latest;
}
if (controller.signal.aborted) break;
await sleep(250);
} }
if (latest && isTerminalStatus(latest.status)) { if (latest && isTerminalStatus(latest.status)) {
logWorker('info', 'sse.wait.terminal_after_close', {
...context,
traceId,
opId,
eventCount,
status: latest.status,
durationMs: Date.now() - startedAt,
});
return latest; return latest;
} }

View file

@ -13,7 +13,12 @@ import { hashUserId } from '@/lib/server/documents/parse-progress-events';
import { getCompute } from '@/lib/server/compute'; import { getCompute } from '@/lib/server/compute';
import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache';
import { UNCLAIMED_USER_ID } from '@/lib/server/storage/docstore-legacy'; import { UNCLAIMED_USER_ID } from '@/lib/server/storage/docstore-legacy';
import type { PdfLayoutJobBase, PdfLayoutProgress } from '@openreader/compute-core/api-contracts'; import type {
PdfLayoutJobBase,
PdfLayoutJobResult,
PdfLayoutProgress,
WorkerOperationState,
} from '@openreader/compute-core/api-contracts';
type UserPdfLayoutJobRequest = PdfLayoutJobBase & { type UserPdfLayoutJobRequest = PdfLayoutJobBase & {
userId: string; userId: string;
@ -24,6 +29,7 @@ const running = new Set<string>();
const FOLLOWER_WAIT_TIMEOUT_MS = 180_000; const FOLLOWER_WAIT_TIMEOUT_MS = 180_000;
const FOLLOWER_POLL_MS = 1_200; const FOLLOWER_POLL_MS = 1_200;
const WORKER_PROGRESS_DB_THROTTLE_MS = 10_000;
type ParseRow = { type ParseRow = {
userId: string; userId: string;
@ -290,7 +296,72 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise<void>
}); });
const compute = await getCompute(); const compute = await getCompute();
const isWorkerCompute = compute.mode === 'worker';
let activeOpId: string = claimOpId;
let activeJobId: string | undefined;
let lastWorkerProgressWriteAt = 0;
let lastWorkerSnapshotWriteAt = 0;
let lastWorkerSnapshotStatus: 'pending' | 'running' | null = null;
let lastWorkerSnapshotOpId: string = claimOpId;
let lastWorkerSnapshotJobId: string | undefined;
const persistRunningState = async (state: DocumentParseState): Promise<void> => {
await updateParseStateForUsers({
documentId: input.documentId,
userIds: cohortUserIds,
parseState: stringifyDocumentParseState(state),
});
await emitParseStateEvents({
documentId: input.documentId,
userIds: cohortUserIds,
state,
});
};
const onWorkerSnapshot = async (snapshot: WorkerOperationState<PdfLayoutJobResult>): Promise<void> => {
if (!isWorkerCompute) return;
if (snapshot.opId) activeOpId = snapshot.opId;
if (snapshot.jobId) activeJobId = snapshot.jobId;
const mappedStatus = snapshot.status === 'queued'
? 'pending'
: snapshot.status === 'running'
? 'running'
: null;
if (!mappedStatus) return;
const now = Date.now();
const forceWrite = (
mappedStatus !== lastWorkerSnapshotStatus
|| activeOpId !== lastWorkerSnapshotOpId
|| activeJobId !== lastWorkerSnapshotJobId
);
if (!forceWrite && (now - lastWorkerSnapshotWriteAt) < WORKER_PROGRESS_DB_THROTTLE_MS) {
return;
}
const nextState: DocumentParseState = {
status: mappedStatus,
progress: snapshot.progress ?? null,
updatedAt: now,
opId: activeOpId,
...(activeJobId ? { jobId: activeJobId } : {}),
};
await persistRunningState(nextState);
lastWorkerSnapshotWriteAt = now;
lastWorkerSnapshotStatus = mappedStatus;
lastWorkerSnapshotOpId = activeOpId;
lastWorkerSnapshotJobId = activeJobId;
};
const writeProgress = async (progress: PdfLayoutProgress): Promise<void> => { const writeProgress = async (progress: PdfLayoutProgress): Promise<void> => {
if (isWorkerCompute) {
const now = Date.now();
if ((now - lastWorkerProgressWriteAt) < WORKER_PROGRESS_DB_THROTTLE_MS && progress.pagesParsed < progress.totalPages) {
return;
}
lastWorkerProgressWriteAt = now;
}
const runningProgressState: DocumentParseState = { const runningProgressState: DocumentParseState = {
status: 'running', status: 'running',
progress: { progress: {
@ -300,18 +371,10 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise<void>
phase: progress.phase, phase: progress.phase,
}, },
updatedAt: Date.now(), updatedAt: Date.now(),
opId: claimOpId, opId: activeOpId,
...(activeJobId ? { jobId: activeJobId } : {}),
}; };
await updateParseStateForUsers({ await persistRunningState(runningProgressState);
documentId: input.documentId,
userIds: cohortUserIds,
parseState: stringifyDocumentParseState(runningProgressState),
});
await emitParseStateEvents({
documentId: input.documentId,
userIds: cohortUserIds,
state: runningProgressState,
});
}; };
const layout = await compute.parsePdfLayout({ const layout = await compute.parsePdfLayout({
documentId: input.documentId, documentId: input.documentId,
@ -319,6 +382,7 @@ export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise<void>
documentObjectKey: documentKey(input.documentId, input.namespace), documentObjectKey: documentKey(input.documentId, input.namespace),
forceToken: input.forceToken, forceToken: input.forceToken,
onProgress: writeProgress, onProgress: writeProgress,
onWorkerSnapshot,
}); });
let parsedJsonKey = layout.parsedObjectKey ?? null; let parsedJsonKey = layout.parsedObjectKey ?? null;