Skip to content

Commit 9ac4bf2

Browse files
authored
Merge pull request #203 from bcgov/feat/ephemeral-document-cleanup
feat: ephemeral-document cleanup (per-workflow blob + Temporal purge)
2 parents b41a218 + 7218ea0 commit 9ac4bf2

14 files changed

Lines changed: 823 additions & 4 deletions

File tree

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const makeDocument = (overrides: Partial<DocumentData> = {}): DocumentData => ({
4848
group_id: "group-1",
4949
created_at: new Date("2024-01-01"),
5050
updated_at: new Date("2024-01-01"),
51+
purged_at: null,
5152
...overrides,
5253
});
5354

@@ -311,6 +312,115 @@ describe("DocumentDbService", () => {
311312
});
312313
});
313314

315+
describe("findPurgeableEphemeralDocuments", () => {
316+
it("matches any ephemeral target, filters status/unpurged, selects config", async () => {
317+
mockPrismaDocument.findMany.mockResolvedValue([]);
318+
319+
await service.findPurgeableEphemeralDocuments(
320+
[DocumentStatus.complete, DocumentStatus.failed],
321+
50,
322+
);
323+
324+
expect(mockPrismaDocument.findMany).toHaveBeenCalledWith({
325+
where: {
326+
status: { in: [DocumentStatus.complete, DocumentStatus.failed] },
327+
purged_at: null,
328+
workflowVersion: {
329+
is: {
330+
OR: [
331+
{ config: { path: ["metadata", "ephemeral"], equals: true } },
332+
{
333+
config: {
334+
path: ["metadata", "ephemeral", "files"],
335+
equals: true,
336+
},
337+
},
338+
{
339+
config: {
340+
path: ["metadata", "ephemeral", "temporalRecord"],
341+
equals: true,
342+
},
343+
},
344+
],
345+
},
346+
},
347+
},
348+
select: {
349+
id: true,
350+
group_id: true,
351+
workflow_execution_id: true,
352+
workflowVersion: { select: { config: true } },
353+
},
354+
orderBy: { updated_at: "asc" },
355+
take: 50,
356+
});
357+
});
358+
359+
it("extracts the ephemeral policy from each workflow config", async () => {
360+
mockPrismaDocument.findMany.mockResolvedValue([
361+
{
362+
id: "d1",
363+
group_id: "g1",
364+
workflow_execution_id: "wf-1",
365+
workflowVersion: { config: { metadata: { ephemeral: true } } },
366+
},
367+
{
368+
id: "d2",
369+
group_id: "g1",
370+
workflow_execution_id: "wf-2",
371+
workflowVersion: {
372+
config: { metadata: { ephemeral: { files: true } } },
373+
},
374+
},
375+
{
376+
id: "d3",
377+
group_id: "g1",
378+
workflow_execution_id: null,
379+
workflowVersion: { config: { metadata: {} } },
380+
},
381+
]);
382+
383+
const result = await service.findPurgeableEphemeralDocuments(
384+
[DocumentStatus.complete],
385+
50,
386+
);
387+
388+
expect(result).toEqual([
389+
{
390+
id: "d1",
391+
group_id: "g1",
392+
workflow_execution_id: "wf-1",
393+
ephemeral: true,
394+
},
395+
{
396+
id: "d2",
397+
group_id: "g1",
398+
workflow_execution_id: "wf-2",
399+
ephemeral: { files: true, temporalRecord: false },
400+
},
401+
{
402+
id: "d3",
403+
group_id: "g1",
404+
workflow_execution_id: null,
405+
ephemeral: false,
406+
},
407+
]);
408+
});
409+
});
410+
411+
describe("markDocumentPurged", () => {
412+
it("stamps purged_at on the document", async () => {
413+
mockPrismaDocument.update.mockResolvedValue({});
414+
415+
await service.markDocumentPurged("d1");
416+
417+
expect(mockPrismaDocument.update).toHaveBeenCalledWith({
418+
where: { id: "d1" },
419+
data: { purged_at: expect.any(Date) },
420+
});
421+
});
422+
});
423+
314424
describe("findOcrResult", () => {
315425
it("should return the OCR result when found", async () => {
316426
const ocrResult: Partial<OcrResult> = {

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

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { EphemeralConfig } from "@ai-di/graph-workflow";
12
import { getErrorMessage } from "@ai-di/shared-logging";
23
import {
34
DocumentStatus,
@@ -16,6 +17,43 @@ import {
1617
import { PrismaService } from "../database/prisma.service";
1718
import type { DocumentData } from "./document-db.types";
1819

20+
/** A terminal document to purge, with its workflow's ephemeral policy. */
21+
export interface PurgeableEphemeralDocument {
22+
id: string;
23+
group_id: string;
24+
workflow_execution_id: string | null;
25+
ephemeral: EphemeralConfig;
26+
}
27+
28+
/**
29+
* Extracts `metadata.ephemeral` from a workflow version config (stored as JSON),
30+
* normalizing the object form to explicit booleans. Returns `false` when absent
31+
* or malformed.
32+
*/
33+
function extractEphemeralConfig(
34+
config: Prisma.JsonValue | null | undefined,
35+
): EphemeralConfig {
36+
if (!config || typeof config !== "object" || Array.isArray(config)) {
37+
return false;
38+
}
39+
const metadata = (config as Record<string, unknown>).metadata;
40+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
41+
return false;
42+
}
43+
const ephemeral = (metadata as Record<string, unknown>).ephemeral;
44+
if (ephemeral === true) {
45+
return true;
46+
}
47+
if (ephemeral && typeof ephemeral === "object" && !Array.isArray(ephemeral)) {
48+
const obj = ephemeral as Record<string, unknown>;
49+
return {
50+
files: obj.files === true,
51+
temporalRecord: obj.temporalRecord === true,
52+
};
53+
}
54+
return false;
55+
}
56+
1957
@Injectable()
2058
export class DocumentDbService {
2159
constructor(
@@ -34,7 +72,7 @@ export class DocumentDbService {
3472
* @returns The created document record.
3573
*/
3674
async createDocument(
37-
data: Omit<DocumentData, "created_at" | "updated_at">,
75+
data: Omit<DocumentData, "created_at" | "updated_at" | "purged_at">,
3876
tx?: Prisma.TransactionClient,
3977
): Promise<DocumentData> {
4078
const client = tx ?? this.prisma;
@@ -361,6 +399,76 @@ export class DocumentDbService {
361399
}
362400
}
363401

402+
/**
403+
* Finds documents eligible for ephemeral cleanup: those processed by a
404+
* workflow whose config declares an ephemeral policy that deletes at least
405+
* one target (`metadata.ephemeral` is `true`, or an object with `files` or
406+
* `temporalRecord` set to `true`), in a terminal status, and not yet purged.
407+
* Ephemerality is configured on the workflow — there is no global or
408+
* per-group setting. Each result carries its workflow's ephemeral policy so
409+
* the janitor knows which targets to delete.
410+
*
411+
* @param statuses - Terminal statuses considered safe to purge.
412+
* @param limit - Maximum number of documents to return in one batch.
413+
* @returns Records (id, group, Temporal execution id, ephemeral policy).
414+
*/
415+
async findPurgeableEphemeralDocuments(
416+
statuses: DocumentStatus[],
417+
limit: number,
418+
): Promise<PurgeableEphemeralDocument[]> {
419+
const rows = await this.prisma.document.findMany({
420+
where: {
421+
status: { in: statuses },
422+
purged_at: null,
423+
workflowVersion: {
424+
is: {
425+
OR: [
426+
{ config: { path: ["metadata", "ephemeral"], equals: true } },
427+
{
428+
config: {
429+
path: ["metadata", "ephemeral", "files"],
430+
equals: true,
431+
},
432+
},
433+
{
434+
config: {
435+
path: ["metadata", "ephemeral", "temporalRecord"],
436+
equals: true,
437+
},
438+
},
439+
],
440+
},
441+
},
442+
},
443+
select: {
444+
id: true,
445+
group_id: true,
446+
workflow_execution_id: true,
447+
workflowVersion: { select: { config: true } },
448+
},
449+
orderBy: { updated_at: "asc" },
450+
take: limit,
451+
});
452+
return rows.map((row) => ({
453+
id: row.id,
454+
group_id: row.group_id,
455+
workflow_execution_id: row.workflow_execution_id,
456+
ephemeral: extractEphemeralConfig(row.workflowVersion?.config),
457+
}));
458+
}
459+
460+
/**
461+
* Stamps a document as purged so the cleanup janitor will not reprocess it.
462+
*
463+
* @param id - The document ID to mark purged.
464+
*/
465+
async markDocumentPurged(id: string): Promise<void> {
466+
await this.prisma.document.update({
467+
where: { id },
468+
data: { purged_at: new Date() },
469+
});
470+
}
471+
364472
/**
365473
* Retrieves the most recent OCR result for a document.
366474
*

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import { UploadNormalizationLimiter } from "../upload/upload-normalization-limit
55
import { DocumentController } from "./document.controller";
66
import { DocumentService } from "./document.service";
77
import { DocumentDbService } from "./document-db.service";
8+
import { EphemeralDocumentCleanupService } from "./ephemeral-document-cleanup.service";
89
import { PdfNormalizationService } from "./pdf-normalization.service";
910

1011
@Module({
1112
imports: [BlobStorageModule, TemporalModule],
1213
providers: [
1314
DocumentDbService,
1415
DocumentService,
16+
EphemeralDocumentCleanupService,
1517
PdfNormalizationService,
1618
UploadNormalizationLimiter,
1719
],

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export class DocumentService {
8888
* @returns The created document record.
8989
*/
9090
async createDocument(
91-
data: Omit<DocumentData, "created_at" | "updated_at">,
91+
data: Omit<DocumentData, "created_at" | "updated_at" | "purged_at">,
9292
tx?: Prisma.TransactionClient,
9393
): Promise<DocumentData> {
9494
this.logger.debug(`DocumentService.createDocument: ${data.id}`);
@@ -195,7 +195,10 @@ export class DocumentService {
195195
);
196196
}
197197

198-
const failedDoc: Omit<DocumentData, "created_at" | "updated_at"> = {
198+
const failedDoc: Omit<
199+
DocumentData,
200+
"created_at" | "updated_at" | "purged_at"
201+
> = {
199202
id: documentId,
200203
title,
201204
original_filename: originalFilename,
@@ -224,7 +227,10 @@ export class DocumentService {
224227
};
225228
}
226229

227-
const documentData: Omit<DocumentData, "created_at" | "updated_at"> = {
230+
const documentData: Omit<
231+
DocumentData,
232+
"created_at" | "updated_at" | "purged_at"
233+
> = {
228234
id: documentId,
229235
title,
230236
original_filename: originalFilename,

0 commit comments

Comments
 (0)