From cc868511d9c8908d04e017d9c61f0b08d1b06521 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 3 Jun 2026 20:50:44 -0600 Subject: [PATCH] fix(documents): handle precondition failures and optimize upload finalization Add detection for precondition failures during blob copy to prevent race conditions when finalizing uploads. Refactor upload finalization to use Promise.all for concurrent processing. Extract parse state and parsed JSON key logic to variables for clarity and to avoid duplication in document registration. --- .../documents/blob/upload/finalize/route.ts | 29 +++++++++++-------- src/lib/server/documents/blobstore.ts | 8 +++++ src/lib/server/documents/register-upload.ts | 28 ++++++++---------- 3 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/app/api/documents/blob/upload/finalize/route.ts b/src/app/api/documents/blob/upload/finalize/route.ts index ea82ce4..8ab877f 100644 --- a/src/app/api/documents/blob/upload/finalize/route.ts +++ b/src/app/api/documents/blob/upload/finalize/route.ts @@ -10,6 +10,7 @@ import { headDocumentBlob, headTempDocumentBlob, isMissingBlobError, + isPreconditionFailed, isValidTempUploadToken, putTempDocumentFinalizeReceipt, } from '@/lib/server/documents/blobstore'; @@ -138,13 +139,18 @@ async function finalizeOne(input: { await headDocumentBlob(documentId, input.namespace); } catch (error) { if (!isMissingBlobError(error)) throw error; - await copyTempDocumentBlobToDocument( - input.upload.token, - input.userId, - documentId, - input.namespace, - temp.contentType, - ); + try { + await copyTempDocumentBlobToDocument( + input.upload.token, + input.userId, + documentId, + input.namespace, + temp.contentType, + { ifNoneMatch: true }, + ); + } catch (copyError) { + if (!isPreconditionFailed(copyError)) throw copyError; + } } const canonicalHead = await headDocumentBlob(documentId, input.namespace); @@ -194,14 +200,13 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'No valid uploads provided' }, { status: 400 }); } - const stored: BaseDocument[] = []; - for (const upload of uploads) { - stored.push(await finalizeOne({ + const stored = await Promise.all( + uploads.map((upload) => finalizeOne({ upload, userId, namespace, - })); - } + })), + ); return NextResponse.json({ stored }); } catch (error) { diff --git a/src/lib/server/documents/blobstore.ts b/src/lib/server/documents/blobstore.ts index b8fa988..36316c9 100644 --- a/src/lib/server/documents/blobstore.ts +++ b/src/lib/server/documents/blobstore.ts @@ -73,6 +73,12 @@ export function isValidDocumentId(id: string): boolean { return DOCUMENT_ID_REGEX.test(id); } +export function isPreconditionFailed(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } }; + return maybe.$metadata?.httpStatusCode === 412 || maybe.name === 'PreconditionFailed'; +} + export function isValidTempUploadToken(token: string): boolean { return TEMP_UPLOAD_TOKEN_REGEX.test(token); } @@ -455,6 +461,7 @@ export async function copyTempDocumentBlobToDocument( documentId: string, namespace: string | null, contentType: string, + options?: { ifNoneMatch?: boolean }, ): Promise { const cfg = getS3Config(); const client = getS3ProxyClient(); @@ -466,6 +473,7 @@ export async function copyTempDocumentBlobToDocument( ContentType: contentType, MetadataDirective: 'REPLACE', ServerSideEncryption: 'AES256', + ...(options?.ifNoneMatch ? { IfNoneMatch: '*' } : {}), }), ); } diff --git a/src/lib/server/documents/register-upload.ts b/src/lib/server/documents/register-upload.ts index 6fe134e..f63daea 100644 --- a/src/lib/server/documents/register-upload.ts +++ b/src/lib/server/documents/register-upload.ts @@ -27,6 +27,14 @@ export async function registerUploadedDocument(input: RegisterUploadedDocumentIn const reusableParsedPdf = input.type === 'pdf' ? await findReusableParsedPdfResult(input.documentId) : null; + const parsedJsonKey = reusableParsedPdf?.parsedJsonKey ?? null; + const parseState = input.type === 'pdf' + ? stringifyDocumentParseState( + reusableParsedPdf + ? { status: 'ready', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION } + : { status: 'pending', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION }, + ) + : null; await db .insert(documents) @@ -38,14 +46,8 @@ export async function registerUploadedDocument(input: RegisterUploadedDocumentIn size: input.size, lastModified: input.lastModified, filePath: input.documentId, - parseState: input.type === 'pdf' - ? stringifyDocumentParseState( - reusableParsedPdf - ? { status: 'ready', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION } - : { status: 'pending', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION }, - ) - : null, - parsedJsonKey: reusableParsedPdf?.parsedJsonKey ?? null, + parseState, + parsedJsonKey, }) .onConflictDoUpdate({ target: [documents.id, documents.userId], @@ -55,14 +57,8 @@ export async function registerUploadedDocument(input: RegisterUploadedDocumentIn size: input.size, lastModified: input.lastModified, filePath: input.documentId, - parseState: input.type === 'pdf' - ? stringifyDocumentParseState( - reusableParsedPdf - ? { status: 'ready', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION } - : { status: 'pending', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION }, - ) - : null, - parsedJsonKey: reusableParsedPdf?.parsedJsonKey ?? null, + parseState, + parsedJsonKey, }, });