Skip to content

Commit 1f3c993

Browse files
authored
Merge pull request #192 from bcgov/AI-1240
Add UploadNormalizationLimiter and overlap writes
2 parents c71e0a0 + 0473b8c commit 1f3c993

9 files changed

Lines changed: 343 additions & 38 deletions

apps/backend-services/src/document/document.module.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import { Module } from "@nestjs/common";
22
import { BlobStorageModule } from "../blob-storage/blob-storage.module";
33
import { TemporalModule } from "../temporal/temporal.module";
4+
import { UploadNormalizationLimiter } from "../upload/upload-normalization-limiter";
45
import { DocumentController } from "./document.controller";
56
import { DocumentService } from "./document.service";
67
import { DocumentDbService } from "./document-db.service";
78
import { PdfNormalizationService } from "./pdf-normalization.service";
89

910
@Module({
1011
imports: [BlobStorageModule, TemporalModule],
11-
providers: [DocumentDbService, DocumentService, PdfNormalizationService],
12+
providers: [
13+
DocumentDbService,
14+
DocumentService,
15+
PdfNormalizationService,
16+
UploadNormalizationLimiter,
17+
],
1218
controllers: [DocumentController],
1319
exports: [DocumentService, PdfNormalizationService],
1420
})

apps/backend-services/src/document/document.service.spec.ts

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
BLOB_STORAGE,
77
BlobStorageInterface,
88
} from "../blob-storage/blob-storage.interface";
9+
import { UploadNormalizationLimiter } from "../upload/upload-normalization-limiter";
910
import { DocumentService } from "./document.service";
1011
import { DocumentDbService } from "./document-db.service";
1112
import { PdfNormalizationService } from "./pdf-normalization.service";
@@ -15,7 +16,13 @@ describe("DocumentService", () => {
1516
let documentDbService: DocumentDbService;
1617
let blobStorage: BlobStorageInterface;
1718
let pdfNormalization: jest.Mocked<
18-
Pick<PdfNormalizationService, "validateForUpload" | "normalizeToPdf">
19+
Pick<
20+
PdfNormalizationService,
21+
"validateForUpload" | "normalizeToPdf" | "generateThumbnailWebp"
22+
>
23+
>;
24+
let uploadNormalizationLimiter: jest.Mocked<
25+
Pick<UploadNormalizationLimiter, "run">
1926
>;
2027

2128
beforeEach(async () => {
@@ -24,7 +31,11 @@ describe("DocumentService", () => {
2431
normalizeToPdf: jest
2532
.fn()
2633
.mockImplementation((buf: Buffer) => Promise.resolve(Buffer.from(buf))),
34+
generateThumbnailWebp: jest.fn().mockResolvedValue(Buffer.from("webp")),
2735
};
36+
uploadNormalizationLimiter = {
37+
run: jest.fn((task: () => Promise<unknown>) => task()),
38+
} as jest.Mocked<Pick<UploadNormalizationLimiter, "run">>;
2839
documentDbService = {
2940
createDocument: jest.fn(),
3041
findDocument: jest.fn(),
@@ -35,7 +46,7 @@ describe("DocumentService", () => {
3546
upsertOcrResult: jest.fn(),
3647
} as any;
3748
blobStorage = {
38-
write: jest.fn(),
49+
write: jest.fn().mockResolvedValue(undefined),
3950
read: jest.fn(),
4051
exists: jest.fn(),
4152
delete: jest.fn(),
@@ -49,6 +60,10 @@ describe("DocumentService", () => {
4960
{ provide: DocumentDbService, useValue: documentDbService },
5061
{ provide: BLOB_STORAGE, useValue: blobStorage },
5162
{ provide: PdfNormalizationService, useValue: pdfNormalization },
63+
{
64+
provide: UploadNormalizationLimiter,
65+
useValue: uploadNormalizationLimiter,
66+
},
5267
],
5368
}).compile();
5469
service = module.get<DocumentService>(DocumentService);
@@ -103,6 +118,121 @@ describe("DocumentService", () => {
103118
expect.stringMatching(/^group-1\/ocr\/.+\/normalized\.pdf$/),
104119
expect.any(Buffer),
105120
);
121+
expect(uploadNormalizationLimiter.run).toHaveBeenCalledTimes(1);
122+
expect(pdfNormalization.generateThumbnailWebp).toHaveBeenCalledWith(
123+
expect.any(Buffer),
124+
"pdf",
125+
);
126+
});
127+
128+
it("writes the normalized blob only after the original write completes", async () => {
129+
const pdfBytes = Buffer.from("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
130+
const base64 = pdfBytes.toString("base64");
131+
const mockDoc = {
132+
id: "1",
133+
title: "Test",
134+
original_filename: "file.pdf",
135+
file_path: "documents/1/original.pdf",
136+
normalized_file_path: "documents/1/normalized.pdf",
137+
file_type: "pdf",
138+
file_size: pdfBytes.length,
139+
metadata: {},
140+
source: "api",
141+
status: DocumentStatus.ongoing_ocr,
142+
created_at: new Date(),
143+
updated_at: new Date(),
144+
model_id: "test-model-id",
145+
group_id: "group-1",
146+
};
147+
const writeOrder: string[] = [];
148+
(documentDbService.createDocument as jest.Mock).mockResolvedValue(
149+
mockDoc,
150+
);
151+
(blobStorage.write as jest.Mock).mockImplementation((key: string) => {
152+
if (key.endsWith("/original.pdf")) {
153+
writeOrder.push("original");
154+
return new Promise<void>((resolve) => {
155+
setTimeout(resolve, 10);
156+
});
157+
}
158+
if (key.endsWith("/normalized.pdf")) {
159+
writeOrder.push("normalized");
160+
}
161+
return Promise.resolve();
162+
});
163+
164+
await service.uploadDocument(
165+
"Test",
166+
base64,
167+
"pdf",
168+
"file.pdf",
169+
"test-model-id",
170+
"group-1",
171+
{},
172+
);
173+
174+
expect(writeOrder).toEqual(["original", "normalized"]);
175+
});
176+
177+
it("starts PDF normalization before the original blob write finishes", async () => {
178+
const pdfBytes = Buffer.from("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
179+
const base64 = pdfBytes.toString("base64");
180+
const mockDoc = {
181+
id: "1",
182+
title: "Test",
183+
original_filename: "file.pdf",
184+
file_path: "documents/1/original.pdf",
185+
normalized_file_path: "documents/1/normalized.pdf",
186+
file_type: "pdf",
187+
file_size: pdfBytes.length,
188+
metadata: {},
189+
source: "api",
190+
status: DocumentStatus.ongoing_ocr,
191+
created_at: new Date(),
192+
updated_at: new Date(),
193+
model_id: "test-model-id",
194+
group_id: "group-1",
195+
};
196+
let resolveOriginalWrite: (() => void) | undefined;
197+
let normalizationStarted = false;
198+
(documentDbService.createDocument as jest.Mock).mockResolvedValue(
199+
mockDoc,
200+
);
201+
(blobStorage.write as jest.Mock).mockImplementation((key: string) => {
202+
if (key.endsWith("/original.pdf")) {
203+
return new Promise<void>((resolve) => {
204+
resolveOriginalWrite = resolve;
205+
});
206+
}
207+
return Promise.resolve();
208+
});
209+
pdfNormalization.normalizeToPdf.mockImplementation(
210+
async (buf: Buffer) => {
211+
normalizationStarted = true;
212+
return Buffer.from(buf);
213+
},
214+
);
215+
216+
const uploadPromise = service.uploadDocument(
217+
"Test",
218+
base64,
219+
"pdf",
220+
"file.pdf",
221+
"test-model-id",
222+
"group-1",
223+
{},
224+
);
225+
226+
await Promise.resolve();
227+
228+
expect(normalizationStarted).toBe(true);
229+
expect(documentDbService.createDocument).not.toHaveBeenCalled();
230+
231+
resolveOriginalWrite?.();
232+
const result = await uploadPromise;
233+
234+
expect(result.kind).toBe("success");
235+
expect(documentDbService.createDocument).toHaveBeenCalled();
106236
});
107237

108238
it("should throw on invalid base64", async () => {

apps/backend-services/src/document/document.service.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
BlobStorageInterface,
1818
} from "../blob-storage/blob-storage.interface";
1919
import { AppLoggerService } from "../logging/app-logger.service";
20+
import { UploadNormalizationLimiter } from "../upload/upload-normalization-limiter";
2021
import { DocumentDbService } from "./document-db.service";
2122
import type { DocumentData } from "./document-db.types";
2223
import { extensionForOriginalBlob } from "./original-blob-key.util";
@@ -55,6 +56,7 @@ export class DocumentService {
5556
@Inject(BLOB_STORAGE)
5657
private readonly blobStorage: BlobStorageInterface,
5758
private readonly pdfNormalization: PdfNormalizationService,
59+
private readonly uploadNormalizationLimiter: UploadNormalizationLimiter,
5860
private readonly logger: AppLoggerService,
5961
) {}
6062

@@ -136,21 +138,30 @@ export class DocumentService {
136138
`original.${extension}`,
137139
);
138140

139-
await this.blobStorage.write(blobKey, fileBuffer);
140-
this.logger.debug(`File saved to blob storage: ${blobKey}`);
141-
142141
const normalizedKey = buildBlobFilePath(
143142
groupId,
144143
OperationCategory.OCR,
145144
[documentId],
146145
"normalized.pdf",
147146
);
147+
const originalWrite = this.blobStorage.write(blobKey, fileBuffer);
148+
// Normalization can run long enough for an early write failure to look
149+
// unhandled; the original promise is still awaited below.
150+
void originalWrite.catch(() => undefined);
151+
this.logger.debug(`Original file write started: ${blobKey}`);
152+
148153
try {
149-
const pdfBuffer = await this.pdfNormalization.normalizeToPdf(
150-
fileBuffer,
151-
fileType,
154+
const pdfBuffer = await this.uploadNormalizationLimiter.run(() =>
155+
this.pdfNormalization.normalizeToPdf(fileBuffer, fileType),
152156
);
157+
await originalWrite;
158+
// Drop the decoded upload buffer before the normalized blob write so
159+
// original bytes and pdf-lib workspace are not retained together.
160+
fileBuffer = Buffer.alloc(0);
153161
await this.blobStorage.write(normalizedKey, pdfBuffer);
162+
this.logger.debug(
163+
`Files saved to blob storage: ${blobKey}, ${normalizedKey}`,
164+
);
154165

155166
const thumbnailKey = buildBlobFilePath(
156167
groupId,
@@ -160,10 +171,7 @@ export class DocumentService {
160171
);
161172
try {
162173
const thumbnailBuffer =
163-
await this.pdfNormalization.generateThumbnailWebp(
164-
fileBuffer,
165-
fileType,
166-
);
174+
await this.pdfNormalization.generateThumbnailWebp(pdfBuffer, "pdf");
167175
await this.blobStorage.write(thumbnailKey, thumbnailBuffer);
168176
this.logger.debug(`Thumbnail saved: ${thumbnailKey}`);
169177
} catch (thumbErr) {
@@ -172,6 +180,12 @@ export class DocumentService {
172180
);
173181
}
174182
} catch (e) {
183+
// Ensure the overlapping original write finished before we record failure.
184+
// We intentionally keep the original blob (no rollback): the API returns
185+
// conversion_failed, status is conversion_failed, normalized_file_path is
186+
// null, OCR is not started, but GET .../download still serves the upload.
187+
await originalWrite;
188+
175189
if (e instanceof BadRequestException) {
176190
throw e;
177191
}

apps/backend-services/src/document/pdf-normalization.service.spec.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -84,19 +84,6 @@ describe("PdfNormalizationService", () => {
8484
).rejects.toThrow(BadRequestException);
8585
});
8686

87-
it("rejects PDF when header is valid but body is truncated", async () => {
88-
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
89-
try {
90-
const full = await minimalValidPdfBuffer();
91-
const truncated = full.subarray(0, Math.floor(full.length / 2));
92-
await expect(
93-
service.validateForUpload(truncated, "pdf"),
94-
).rejects.toThrow(BadRequestException);
95-
} finally {
96-
warnSpy.mockRestore();
97-
}
98-
});
99-
10087
it("accepts a minimal valid PDF", async () => {
10188
const buf = await minimalValidPdfBuffer();
10289
await expect(
@@ -125,6 +112,14 @@ describe("PdfNormalizationService", () => {
125112
});
126113

127114
describe("normalizeToPdf", () => {
115+
it("rejects PDF when header is valid but body is truncated", async () => {
116+
const full = await minimalValidPdfBuffer();
117+
const truncated = full.subarray(0, Math.floor(full.length / 2));
118+
await expect(service.normalizeToPdf(truncated, "pdf")).rejects.toThrow(
119+
BadRequestException,
120+
);
121+
});
122+
128123
it("returns a copy of the buffer for pdf", async () => {
129124
const buf = await minimalValidPdfBuffer();
130125
const out = await service.normalizeToPdf(buf, "pdf");

apps/backend-services/src/document/pdf-normalization.service.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ export class PdfNormalizationService {
3131

3232
/**
3333
* Validates upload bytes before persisting the original blob.
34+
*
35+
* PDF/scan inputs are checked with a cheap magic-byte probe only; full
36+
* pdf-lib parsing happens once in {@link normalizeToPdf} to avoid holding
37+
* two document workspaces in memory at the same time.
38+
*
3439
* @throws BadRequestException for corrupt or unsupported inputs.
3540
*/
3641
async validateForUpload(fileBuffer: Buffer, fileType: string): Promise<void> {
@@ -44,13 +49,6 @@ export class PdfNormalizationService {
4449
"The file is not a valid PDF or is corrupted.",
4550
);
4651
}
47-
try {
48-
await PDFDocument.load(fileBuffer, { ignoreEncryption: true });
49-
} catch {
50-
throw new BadRequestException(
51-
"The file is not a valid PDF or is corrupted.",
52-
);
53-
}
5452
return;
5553
}
5654
if (ft === "image") {
@@ -236,7 +234,14 @@ export class PdfNormalizationService {
236234
* Keep both sites in sync when changing the math.
237235
*/
238236
private async normalizePdfPageRotations(fileBuffer: Buffer): Promise<Buffer> {
239-
const srcDoc = await PDFDocument.load(fileBuffer);
237+
let srcDoc: PDFDocument;
238+
try {
239+
srcDoc = await PDFDocument.load(fileBuffer, { ignoreEncryption: true });
240+
} catch {
241+
throw new BadRequestException(
242+
"The file is not a valid PDF or is corrupted.",
243+
);
244+
}
240245
const pageCount = srcDoc.getPageCount();
241246

242247
const hasRotation = srcDoc

0 commit comments

Comments
 (0)