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.
This commit is contained in:
Richard R 2026-06-03 20:50:44 -06:00
parent 6c86720926
commit cc868511d9
3 changed files with 37 additions and 28 deletions

View file

@ -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) {

View file

@ -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<void> {
const cfg = getS3Config();
const client = getS3ProxyClient();
@ -466,6 +473,7 @@ export async function copyTempDocumentBlobToDocument(
ContentType: contentType,
MetadataDirective: 'REPLACE',
ServerSideEncryption: 'AES256',
...(options?.ifNoneMatch ? { IfNoneMatch: '*' } : {}),
}),
);
}

View file

@ -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,
},
});