feat(pdf,worker): enhance PDF layout parsing UX and add robust timeout controls
Introduce idle and hard cap timeouts for PDF layout parsing in the compute worker, ensuring long-running jobs are capped and progress is tracked with explicit timeouts. Refactor NATS JetStream client initialization to use a configurable API timeout. Update PDF viewer UI to treat parse state as 'pending' by default, improving clarity during document preparation and parsing. Refine parse loader labels and progress logic for more accurate user feedback.
This commit is contained in:
parent
a2c714c2a5
commit
671d5c6adb
3 changed files with 93 additions and 31 deletions
|
|
@ -55,6 +55,8 @@ const RUNNING_HEARTBEAT_MS = 5000;
|
||||||
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;
|
||||||
|
const PDF_LAYOUT_HARD_CAP_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const NATS_API_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
interface QueuedJob<TPayload> {
|
interface QueuedJob<TPayload> {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
|
@ -212,6 +214,59 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withIdleTimeoutAndHardCap<T>(input: {
|
||||||
|
run: (touchProgress: () => void) => Promise<T>;
|
||||||
|
idleTimeoutMs: number;
|
||||||
|
hardCapMs: number;
|
||||||
|
label: string;
|
||||||
|
}): Promise<T> {
|
||||||
|
let idleTimer: NodeJS.Timeout | null = null;
|
||||||
|
let hardCapTimer: NodeJS.Timeout | null = null;
|
||||||
|
let settled = false;
|
||||||
|
let rejectTimeout!: (reason: unknown) => void;
|
||||||
|
|
||||||
|
const clearTimers = () => {
|
||||||
|
if (idleTimer) {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
idleTimer = null;
|
||||||
|
}
|
||||||
|
if (hardCapTimer) {
|
||||||
|
clearTimeout(hardCapTimer);
|
||||||
|
hardCapTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const failTimeout = (kind: 'idle' | 'hard cap', timeoutMs: number) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimers();
|
||||||
|
rejectTimeout(new Error(`${input.label} ${kind} timed out after ${timeoutMs}ms`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchProgress = () => {
|
||||||
|
if (settled) return;
|
||||||
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
idleTimer = setTimeout(() => failTimeout('idle', input.idleTimeoutMs), input.idleTimeoutMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
rejectTimeout = reject;
|
||||||
|
hardCapTimer = setTimeout(() => failTimeout('hard cap', input.hardCapMs), input.hardCapMs);
|
||||||
|
touchProgress();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([input.run(touchProgress), timeoutPromise]);
|
||||||
|
settled = true;
|
||||||
|
clearTimers();
|
||||||
|
return result as T;
|
||||||
|
} catch (error) {
|
||||||
|
settled = true;
|
||||||
|
clearTimers();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isAuthed(request: FastifyRequest, expectedToken: string): boolean {
|
function isAuthed(request: FastifyRequest, expectedToken: string): boolean {
|
||||||
const auth = request.headers.authorization;
|
const auth = request.headers.authorization;
|
||||||
if (!auth?.startsWith('Bearer ')) return false;
|
if (!auth?.startsWith('Bearer ')) return false;
|
||||||
|
|
@ -401,8 +456,8 @@ async function main(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const nc: NatsConnection = await connect(connectOpts);
|
const nc: NatsConnection = await connect(connectOpts);
|
||||||
const js: JetStreamClient = jetstream(nc);
|
const js: JetStreamClient = jetstream(nc, { timeout: NATS_API_TIMEOUT_MS });
|
||||||
const jsm: JetStreamManager = await jetstreamManager(nc);
|
const jsm: JetStreamManager = await jetstreamManager(nc, { timeout: NATS_API_TIMEOUT_MS });
|
||||||
|
|
||||||
await ensureJetStreamResources(jsm, whisperTimeoutMs, pdfTimeoutMs, pdfAttempts, jobsStreamMaxBytes);
|
await ensureJetStreamResources(jsm, whisperTimeoutMs, pdfTimeoutMs, pdfAttempts, jobsStreamMaxBytes);
|
||||||
|
|
||||||
|
|
@ -710,6 +765,7 @@ async function main(): Promise<void> {
|
||||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
const writeSnapshot = (snapshot: WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>): void => {
|
const writeSnapshot = (snapshot: WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>): void => {
|
||||||
|
if (closed || reply.raw.writableEnded) return;
|
||||||
reply.raw.write(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`);
|
reply.raw.write(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -718,25 +774,34 @@ async function main(): Promise<void> {
|
||||||
closed = true;
|
closed = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
let current = initial;
|
try {
|
||||||
writeSnapshot(current);
|
let current = initial;
|
||||||
let signature = JSON.stringify(current);
|
writeSnapshot(current);
|
||||||
|
let signature = JSON.stringify(current);
|
||||||
|
|
||||||
while (!closed && !isTerminalStatus(current.status)) {
|
while (!closed && !isTerminalStatus(current.status)) {
|
||||||
await sleep(SSE_POLL_INTERVAL_MS);
|
await sleep(SSE_POLL_INTERVAL_MS);
|
||||||
const next = await getOpState(params.data.opId);
|
const next = await getOpState(params.data.opId);
|
||||||
if (!next) break;
|
if (!next) break;
|
||||||
const nextSignature = JSON.stringify(next);
|
const nextSignature = JSON.stringify(next);
|
||||||
if (nextSignature !== signature) {
|
if (nextSignature !== signature) {
|
||||||
current = next;
|
current = next;
|
||||||
signature = nextSignature;
|
signature = nextSignature;
|
||||||
writeSnapshot(current);
|
writeSnapshot(current);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({
|
||||||
|
opId: params.data.opId,
|
||||||
|
error: toErrorMessage(error),
|
||||||
|
}, 'op events stream loop error');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reply.raw.writableEnded) {
|
if (!reply.raw.writableEnded) {
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return reply;
|
||||||
});
|
});
|
||||||
|
|
||||||
const whisperConsumer = await js.consumers.get(JOBS_STREAM_NAME, WHISPER_CONSUMER_NAME);
|
const whisperConsumer = await js.consumers.get(JOBS_STREAM_NAME, WHISPER_CONSUMER_NAME);
|
||||||
|
|
@ -791,11 +856,15 @@ async function main(): Promise<void> {
|
||||||
let lastTotalPages = 0;
|
let lastTotalPages = 0;
|
||||||
let lastPagesParsed = 0;
|
let lastPagesParsed = 0;
|
||||||
const computeStartedAt = Date.now();
|
const computeStartedAt = Date.now();
|
||||||
const result = await withTimeout(
|
const result = await withIdleTimeoutAndHardCap({
|
||||||
runPdfLayoutFromPdfBuffer({
|
idleTimeoutMs: Math.max(pdfTimeoutMs, 1_000),
|
||||||
|
hardCapMs: PDF_LAYOUT_HARD_CAP_MS,
|
||||||
|
label: 'pdf layout job',
|
||||||
|
run: async (touchProgress) => runPdfLayoutFromPdfBuffer({
|
||||||
documentId: parsed.documentId,
|
documentId: parsed.documentId,
|
||||||
pdfBytes,
|
pdfBytes,
|
||||||
onPageParsed: async ({ pageNumber, totalPages }) => {
|
onPageParsed: async ({ pageNumber, totalPages }) => {
|
||||||
|
touchProgress();
|
||||||
lastTotalPages = totalPages;
|
lastTotalPages = totalPages;
|
||||||
lastPagesParsed = pageNumber;
|
lastPagesParsed = pageNumber;
|
||||||
if (!hooks?.onProgress) return;
|
if (!hooks?.onProgress) return;
|
||||||
|
|
@ -807,9 +876,7 @@ async function main(): Promise<void> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
pdfTimeoutMs,
|
});
|
||||||
'pdf layout job',
|
|
||||||
);
|
|
||||||
|
|
||||||
const computeMs = Date.now() - computeStartedAt;
|
const computeMs = Date.now() - computeStartedAt;
|
||||||
if (hooks?.onProgress && lastTotalPages > 0) {
|
if (hooks?.onProgress && lastTotalPages > 0) {
|
||||||
|
|
|
||||||
|
|
@ -75,14 +75,14 @@ export default function PDFViewerPage() {
|
||||||
const backNavTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const backNavTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const clearCurrDocRef = useRef(clearCurrDoc);
|
const clearCurrDocRef = useRef(clearCurrDoc);
|
||||||
const [isNavigatingBack, setIsNavigatingBack] = useState(false);
|
const [isNavigatingBack, setIsNavigatingBack] = useState(false);
|
||||||
const parseUiState: 'unknown' | NonNullable<typeof parseStatus> = parseStatus ?? 'unknown';
|
const parseUiState: NonNullable<typeof parseStatus> = parseStatus ?? 'pending';
|
||||||
const isParseReady = parseUiState === 'ready';
|
const isParseReady = parseUiState === 'ready';
|
||||||
const forceReparseDisabled = isForceReparseDisabled(parseStatus);
|
const forceReparseDisabled = isForceReparseDisabled(parseStatus);
|
||||||
const hasRealParseProgress = !!parseProgress
|
const hasRealParseProgress = !!parseProgress
|
||||||
&& parseProgress.totalPages > 0
|
&& parseProgress.totalPages > 0
|
||||||
&& parseProgress.pagesParsed >= 0;
|
&& parseProgress.pagesParsed >= 0;
|
||||||
const shouldShowExpandedParseLoader = !isLoading
|
const shouldShowExpandedParseLoader = !isLoading
|
||||||
&& (parseUiState === 'failed' || hasRealParseProgress);
|
&& (parseUiState === 'pending' || parseUiState === 'running' || parseUiState === 'failed' || hasRealParseProgress);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
@ -286,10 +286,10 @@ export default function PDFViewerPage() {
|
||||||
const renderPdfStatusLoader = () => {
|
const renderPdfStatusLoader = () => {
|
||||||
const compactLabel = isLoading
|
const compactLabel = isLoading
|
||||||
? 'Opening PDF...'
|
? 'Opening PDF...'
|
||||||
: (parseUiState === 'ready' ? 'Rendering pages...' : 'Checking parse state...');
|
: (parseUiState === 'ready' ? 'Rendering pages...' : 'Preparing PDF layout...');
|
||||||
const compactSubLabel = isLoading
|
const compactSubLabel = isLoading
|
||||||
? 'Loading document data'
|
? 'Loading document data'
|
||||||
: (parseUiState === 'ready' ? 'Preparing first frame' : 'Waiting for parse status');
|
: (parseUiState === 'ready' ? 'Preparing first frame' : 'Queueing parser and preparing page extraction');
|
||||||
|
|
||||||
const totalPages = parseProgress?.totalPages ?? 0;
|
const totalPages = parseProgress?.totalPages ?? 0;
|
||||||
const pagesParsed = parseProgress?.pagesParsed ?? 0;
|
const pagesParsed = parseProgress?.pagesParsed ?? 0;
|
||||||
|
|
@ -302,10 +302,7 @@ export default function PDFViewerPage() {
|
||||||
let statusText = 'Loading PDF...';
|
let statusText = 'Loading PDF...';
|
||||||
let statusSubText = 'Initializing document renderer';
|
let statusSubText = 'Initializing document renderer';
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
if (parseUiState === 'unknown') {
|
if (parseUiState === 'pending') {
|
||||||
statusText = 'Checking parse state...';
|
|
||||||
statusSubText = 'Waiting for server status snapshot';
|
|
||||||
} else if (parseUiState === 'pending') {
|
|
||||||
statusText = 'Preparing PDF layout...';
|
statusText = 'Preparing PDF layout...';
|
||||||
statusSubText = parseProgress?.phase === 'merge'
|
statusSubText = parseProgress?.phase === 'merge'
|
||||||
? 'Finalizing stitched block structure'
|
? 'Finalizing stitched block structure'
|
||||||
|
|
@ -321,9 +318,7 @@ export default function PDFViewerPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stageLabel = parseUiState === 'unknown'
|
const stageLabel = parseUiState === 'failed'
|
||||||
? 'Stage: checking'
|
|
||||||
: parseUiState === 'failed'
|
|
||||||
? 'Stage: blocked'
|
? 'Stage: blocked'
|
||||||
: (parseUiState === 'pending'
|
: (parseUiState === 'pending'
|
||||||
? 'Stage: prepare'
|
? 'Stage: prepare'
|
||||||
|
|
|
||||||
|
|
@ -450,7 +450,7 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
setCurrDocName(undefined);
|
setCurrDocName(undefined);
|
||||||
setCurrDocData(undefined);
|
setCurrDocData(undefined);
|
||||||
setParsedDocument(null);
|
setParsedDocument(null);
|
||||||
setParseStatus(null);
|
setParseStatus('pending');
|
||||||
setParseProgress(null);
|
setParseProgress(null);
|
||||||
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue