fix(pdf): prevent redundant parse jobs and improve docx-to-pdf upload reuse

Avoid enqueuing duplicate parse jobs for PDFs that are already parsed or in terminal states. Update docx-to-pdf upload to reuse existing parsed PDF results when possible, skipping unnecessary parse operations and job enqueuing. Adjust parse-state-backfill to only enqueue jobs for pending or running states. Refine client upload logic to better infer document type from MIME when file names are missing. Enhance usePdfDocument to handle terminal parse states more robustly.

This change ensures idempotent parse job creation and reduces unnecessary worker load, while improving document upload and parse state handling.
This commit is contained in:
Richard R 2026-06-04 12:47:37 -06:00
parent 62c913e96d
commit 7344fcbc51
6 changed files with 90 additions and 47 deletions

View file

@ -213,31 +213,42 @@ export function usePdfDocument(): PdfDocumentState {
setActiveParseOpId(initialOpId?.trim() || null); setActiveParseOpId(initialOpId?.trim() || null);
const controller = new AbortController(); const controller = new AbortController();
parseStreamAbortRef.current = controller; parseStreamAbortRef.current = controller;
let isResolvingTerminalState = false;
const closeSse = subscribeParsedPdfDocumentEvents(documentId, { const closeSse = subscribeParsedPdfDocumentEvents(documentId, {
opId: initialOpId?.trim() || null, opId: initialOpId?.trim() || null,
}, { }, {
onSnapshot: (snapshot) => { onSnapshot: (snapshot) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
if (isResolvingTerminalState) return;
if (typeof snapshot.opId === 'string' && snapshot.opId.trim()) { if (typeof snapshot.opId === 'string' && snapshot.opId.trim()) {
setActiveParseOpId(snapshot.opId.trim()); setActiveParseOpId(snapshot.opId.trim());
} }
setParseStatus(snapshot.parseStatus); setParseStatus(snapshot.parseStatus);
setParseProgress(snapshot.parseProgress); setParseProgress(snapshot.parseProgress);
if (snapshot.parseStatus === 'ready') { if (snapshot.parseStatus === 'ready') {
closeSse(); isResolvingTerminalState = true;
parseSseCloseRef.current = null; void (async () => {
if (parseStreamAbortRef.current === controller) { try {
parseStreamAbortRef.current = null; await loadParsedDocumentOnce(documentId, controller.signal);
} } catch (error) {
void loadParsedDocumentOnce(documentId, controller.signal).catch((error) => { if (error instanceof DOMException && error.name === 'AbortError') return;
if (error instanceof DOMException && error.name === 'AbortError') return; console.error('Failed to load parsed PDF after ready status:', error);
console.error('Failed to load parsed PDF after ready status:', error); resetParsedDocumentState();
resetParsedDocumentState(); } finally {
}); if (parseSseCloseRef.current === closeSse) {
closeSse();
parseSseCloseRef.current = null;
}
if (parseStreamAbortRef.current === controller) {
parseStreamAbortRef.current = null;
}
}
})();
return; return;
} }
if (snapshot.parseStatus === 'failed') { if (snapshot.parseStatus === 'failed') {
isResolvingTerminalState = true;
closeSse(); closeSse();
parseSseCloseRef.current = null; parseSseCloseRef.current = null;
if (parseStreamAbortRef.current === controller) { if (parseStreamAbortRef.current === controller) {

View file

@ -8,6 +8,7 @@ import { pathToFileURL } from 'url';
import { requireAuthContext } from '@/lib/server/auth/auth'; import { requireAuthContext } from '@/lib/server/auth/auth';
import { db } from '@/db'; import { db } from '@/db';
import { documents } from '@/db/schema'; import { documents } from '@/db/schema';
import { findReusableParsedPdfResult } from '@/lib/server/documents/parsed-pdf-reuse';
import { safeDocumentName } from '@/lib/server/documents/utils'; import { safeDocumentName } from '@/lib/server/documents/utils';
import { enqueueDocumentPreview } from '@/lib/server/documents/previews'; import { enqueueDocumentPreview } from '@/lib/server/documents/previews';
import { startPdfParseOperation } from '@/lib/server/documents/pdf-parse-operation'; import { startPdfParseOperation } from '@/lib/server/documents/pdf-parse-operation';
@ -18,6 +19,7 @@ import { isS3Configured } from '@/lib/server/storage/s3';
import { putDocumentBlob } from '@/lib/server/documents/blobstore'; import { putDocumentBlob } from '@/lib/server/documents/blobstore';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response'; import { errorResponse } from '@/lib/server/errors/next-response';
import { PDF_PARSER_VERSION } from '@openreader/compute-core';
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
@ -130,11 +132,19 @@ export async function POST(req: NextRequest) {
const derivedName = safeDocumentName(`${path.parse(file.name).name}.pdf`, `${id}.pdf`); const derivedName = safeDocumentName(`${path.parse(file.name).name}.pdf`, `${id}.pdf`);
const lastModified = Number.isFinite(file.lastModified) ? file.lastModified : Date.now(); const lastModified = Number.isFinite(file.lastModified) ? file.lastModified : Date.now();
const startedParse = await startPdfParseOperation({ const reusableParsedPdf = await findReusableParsedPdfResult(id);
documentId: id, const startedParse = reusableParsedPdf
userId: storageUserId, ? null
namespace: testNamespace, : await startPdfParseOperation({
}); documentId: id,
userId: storageUserId,
namespace: testNamespace,
});
const parseState = stringifyDocumentParseState(
reusableParsedPdf
? { status: 'ready', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION }
: startedParse!.parseState,
);
await db await db
.insert(documents) .insert(documents)
@ -146,8 +156,8 @@ export async function POST(req: NextRequest) {
size: pdfContent.length, size: pdfContent.length,
lastModified, lastModified,
filePath: id, filePath: id,
parseState: stringifyDocumentParseState(startedParse.parseState), parseState,
parsedJsonKey: null, parsedJsonKey: reusableParsedPdf?.parsedJsonKey ?? null,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [documents.id, documents.userId], target: [documents.id, documents.userId],
@ -157,8 +167,8 @@ export async function POST(req: NextRequest) {
size: pdfContent.length, size: pdfContent.length,
lastModified, lastModified,
filePath: id, filePath: id,
parseState: stringifyDocumentParseState(startedParse.parseState), parseState,
parsedJsonKey: null, parsedJsonKey: reusableParsedPdf?.parsedJsonKey ?? null,
}, },
}); });
@ -179,14 +189,16 @@ export async function POST(req: NextRequest) {
}, 'Failed to enqueue preview for converted DOCX'); }, 'Failed to enqueue preview for converted DOCX');
}); });
enqueueParsePdfJob({ if (startedParse) {
documentId: id, enqueueParsePdfJob({
userId: storageUserId, documentId: id,
namespace: testNamespace, userId: storageUserId,
initialOpId: startedParse.workerState.opId, namespace: testNamespace,
initialJobId: startedParse.workerState.jobId, initialOpId: startedParse.workerState.opId,
initialStatus: startedParse.parseState.status === 'running' ? 'running' : 'pending', initialJobId: startedParse.workerState.jobId,
}); initialStatus: startedParse.parseState.status === 'running' ? 'running' : 'pending',
});
}
return NextResponse.json({ return NextResponse.json({
stored: { stored: {

View file

@ -47,6 +47,14 @@ function documentTypeForName(name: string): DocumentType {
return 'html'; return 'html';
} }
function documentTypeForMime(contentType: string): DocumentType | null {
const normalized = contentType.trim().toLowerCase();
if (normalized === 'application/pdf') return 'pdf';
if (normalized === 'application/epub+zip') return 'epub';
if (normalized === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'docx';
return null;
}
export function mimeTypeForDoc(doc: Pick<BaseDocument, 'type' | 'name'>): string { export function mimeTypeForDoc(doc: Pick<BaseDocument, 'type' | 'name'>): string {
if (doc.type === 'pdf') return 'application/pdf'; if (doc.type === 'pdf') return 'application/pdf';
if (doc.type === 'epub') return 'application/epub+zip'; if (doc.type === 'epub') return 'application/epub+zip';
@ -314,11 +322,14 @@ export async function uploadDocuments(files: File[], options?: UploadOptions): P
const sources: UploadSource[] = []; const sources: UploadSource[] = [];
for (const file of files) { for (const file of files) {
const type = documentTypeForName(file.name); const name = file.name || '';
const name = file.name || `upload.${type}`; const type = name
const contentType = file.type || mimeTypeForDoc({ name, type }); ? documentTypeForName(name)
: (documentTypeForMime(file.type) ?? 'html');
const resolvedName = name || `upload.${type}`;
const contentType = file.type || mimeTypeForDoc({ name: resolvedName, type });
sources.push({ sources.push({
name, name: resolvedName,
type, type,
size: file.size, size: file.size,
lastModified: Number.isFinite(file.lastModified) ? file.lastModified : Date.now(), lastModified: Number.isFinite(file.lastModified) ? file.lastModified : Date.now(),

View file

@ -68,7 +68,7 @@ export function mergeNonReadyParseSnapshot(input: {
workerState: WorkerOperationState<PdfLayoutJobResult>; workerState: WorkerOperationState<PdfLayoutJobResult>;
}): { parseStatus: PdfParseStatus; parseProgress: PdfParseProgress | null } { }): { parseStatus: PdfParseStatus; parseProgress: PdfParseProgress | null } {
const workerSnapshot = snapshotFromWorkerState(input.workerState); const workerSnapshot = snapshotFromWorkerState(input.workerState);
if (workerSnapshot.parseStatus === 'ready' || workerSnapshot.parseStatus === 'failed') { if (workerSnapshot.parseStatus === 'ready') {
return { return {
parseStatus: input.parseStatus, parseStatus: input.parseStatus,
parseProgress: input.parseProgress, parseProgress: input.parseProgress,

View file

@ -23,13 +23,15 @@ export async function backfillPendingPdfParseOperation(input: {
userId: input.userId, userId: input.userId,
namespace: input.namespace, namespace: input.namespace,
}); });
enqueueParsePdfJob({ if (startedParse.parseState.status === 'pending' || startedParse.parseState.status === 'running') {
documentId: input.documentId, enqueueParsePdfJob({
userId: input.userId, documentId: input.documentId,
namespace: input.namespace, userId: input.userId,
initialOpId: startedParse.workerState.opId, namespace: input.namespace,
initialJobId: startedParse.workerState.jobId, initialOpId: startedParse.workerState.opId,
initialStatus: startedParse.parseState.status === 'running' ? 'running' : 'pending', initialJobId: startedParse.workerState.jobId,
}); initialStatus: startedParse.parseState.status,
});
}
return startedParse.workerState; return startedParse.workerState;
} }

View file

@ -14,8 +14,9 @@ const hoisted = vi.hoisted(() => ({
}, },
requireAuthContext: vi.fn(), requireAuthContext: vi.fn(),
fetchWorkerOperationState: vi.fn(), fetchWorkerOperationState: vi.fn(),
backfillPendingPdfParseOperation: vi.fn(),
healStaleDocumentParseState: vi.fn(async ({ state }) => state), healStaleDocumentParseState: vi.fn(async ({ state }) => state),
startPdfParseOperation: vi.fn(),
enqueueParsePdfJob: vi.fn(),
})); }));
vi.mock('@/db', () => ({ vi.mock('@/db', () => ({
@ -32,14 +33,18 @@ vi.mock('@/lib/server/compute/worker-op-state', () => ({
fetchWorkerOperationState: hoisted.fetchWorkerOperationState, fetchWorkerOperationState: hoisted.fetchWorkerOperationState,
})); }));
vi.mock('@/lib/server/documents/parse-state-backfill', () => ({
backfillPendingPdfParseOperation: hoisted.backfillPendingPdfParseOperation,
}));
vi.mock('@/lib/server/documents/parse-state-healing', () => ({ vi.mock('@/lib/server/documents/parse-state-healing', () => ({
healStaleDocumentParseState: hoisted.healStaleDocumentParseState, healStaleDocumentParseState: hoisted.healStaleDocumentParseState,
})); }));
vi.mock('@/lib/server/documents/pdf-parse-operation', () => ({
startPdfParseOperation: hoisted.startPdfParseOperation,
}));
vi.mock('@/lib/server/jobs/user-pdf-layout-job', () => ({
enqueueParsePdfJob: hoisted.enqueueParsePdfJob,
}));
vi.mock('@/lib/server/documents/blobstore', () => ({ vi.mock('@/lib/server/documents/blobstore', () => ({
documentKey: vi.fn(), documentKey: vi.fn(),
getParsedDocumentBlob: vi.fn(), getParsedDocumentBlob: vi.fn(),
@ -104,8 +109,9 @@ describe('GET /api/documents/[id]/parsed pure data fetch', () => {
hoisted.requireAuthContext.mockResolvedValue({ userId: 'user-1' }); hoisted.requireAuthContext.mockResolvedValue({ userId: 'user-1' });
hoisted.fetchWorkerOperationState.mockReset(); hoisted.fetchWorkerOperationState.mockReset();
hoisted.fetchWorkerOperationState.mockResolvedValue(null); hoisted.fetchWorkerOperationState.mockResolvedValue(null);
hoisted.backfillPendingPdfParseOperation.mockReset();
hoisted.healStaleDocumentParseState.mockClear(); hoisted.healStaleDocumentParseState.mockClear();
hoisted.startPdfParseOperation.mockReset();
hoisted.enqueueParsePdfJob.mockReset();
}); });
test('returns non-ready status without creating a worker op for legacy pending PDFs without opId', async () => { test('returns non-ready status without creating a worker op for legacy pending PDFs without opId', async () => {
@ -120,7 +126,8 @@ describe('GET /api/documents/[id]/parsed pure data fetch', () => {
parseStatus: 'pending', parseStatus: 'pending',
opId: null, opId: null,
}); });
expect(hoisted.backfillPendingPdfParseOperation).not.toHaveBeenCalled(); expect(hoisted.startPdfParseOperation).not.toHaveBeenCalled();
expect(hoisted.enqueueParsePdfJob).not.toHaveBeenCalled();
expect(hoisted.row.parseState).toBeNull(); expect(hoisted.row.parseState).toBeNull();
}); });
}); });