Files
HealthLog/src/lib/jobs/document-thumbnail.ts
T

227 lines
8.0 KiB
TypeScript

/**
* Automatic per-document preview thumbnail, enqueued the moment a document is
* stored (`POST /api/documents/inbound`). Fire-and-forget: the upload response
* never blocks on or fails because of thumbnail rendering.
*
* The job decrypts the stored original (owner-scoped), renders a small JPEG
* preview (`generateThumbnail` — images downscaled, a PDF's first page
* rasterised, every other type skipped), and upserts it encrypted into the 1:1
* `DocumentThumbnail` side table. Pure local compute — no egress, no consent
* or budget gate. Serial concurrency keeps the canvas/pdfjs decode off the
* request pool.
*
* NEVER fails a document for an expected outcome: an unsupported MIME or an
* unrenderable file leaves the document without a thumbnail (the card shows its
* kind icon), and `runDocumentThumbnail` returns cleanly so pg-boss does not
* retry it. Only an unexpected throw (a DB hiccup) propagates for retry.
*
* Each run is one background wide event (`job.document_thumbnail`). A run
* that should have produced a preview and did not is raised to `warn` with
* the reason, so a document left with its kind icon is explained in the log.
* Without the event every annotation below went nowhere: a whole class of
* PDFs went without previews for weeks and the log never said so.
*
* The queue MUST be registered in the maintenance registrar
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
*/
import { prisma } from "@/lib/db";
import {
decryptDocumentContent,
encryptThumbnail,
} from "@/lib/documents/store";
import { withPdfWork } from "@/lib/documents/pdf-isolation";
import { PdfReaderUnavailableError } from "@/lib/documents/pdfjs";
import { generateThumbnail } from "@/lib/documents/thumbnail";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { withBackgroundEvent } from "@/lib/logging/background";
import { annotate } from "@/lib/logging/context";
import { logCaught } from "@/lib/logging/signal";
import { DOCUMENT_THUMBNAIL_QUEUE } from "./document-thumbnail-queue";
export interface DocumentThumbnailPayload {
userId: string;
documentId: string;
enqueuedAt?: string;
}
/**
* Render + store one document's preview thumbnail (owner-scoped). Idempotent:
* a document that already has a thumbnail is skipped, and an unsupported /
* unrenderable document is left without one. Never throws for an expected
* outcome.
*/
export async function runDocumentThumbnail(
payload: DocumentThumbnailPayload,
): Promise<void> {
const { userId, documentId } = payload;
if (!userId || !documentId) return;
await withBackgroundEvent("job.document_thumbnail", async (evt) => {
evt.addMeta("document_id", documentId);
await withPdfWork({ owner: userId, lane: "background" }, () =>
renderThumbnail(userId, documentId, (reason) => {
evt.addMeta("thumbnail_missing_reason", reason);
evt.elevateLevel("warn");
}),
);
});
}
async function renderThumbnail(
userId: string,
documentId: string,
warn: (reason: string) => void,
): Promise<void> {
// Owner-scoped load; the blob column is selected ONLY here (the job that
// renders the preview), never in the list/detail queries. Skip a document
// that already has a thumbnail so a re-enqueue is a cheap no-op.
const document = await prisma.inboundDocument.findFirst({
where: { id: documentId, userId, deletedAt: null },
select: {
id: true,
mimeType: true,
contentEncrypted: true,
contentCodec: true,
thumbnail: { select: { id: true } },
},
});
if (!document || document.thumbnail) {
annotate({
action: { name: "documents.thumbnail.skipped" },
meta: { documentId, reason: !document ? "not-found" : "exists" },
});
return;
}
let bytes: Buffer;
try {
bytes = decryptDocumentContent(
document.contentEncrypted,
document.contentCodec,
);
} catch {
// Fail-closed decrypt: a bad / missing key id leaves the document without a
// thumbnail (the card shows its icon); never log the bytes.
annotate({
action: { name: "documents.thumbnail.decryptFailed" },
meta: { documentId },
});
warn("decrypt-failed");
await recordThumbnailFailure(userId, document.id, "decrypt-failed");
return;
}
const result = await generateThumbnail(bytes, document.mimeType);
if (!result.ok && result.reason === "reader-busy") {
// The reader was taken by other work for longer than a background item
// waits. Nothing is wrong with the document: pg-boss runs the job again.
throw new PdfReaderUnavailableError("busy");
}
if (!result.ok) {
annotate({
action: { name: "documents.thumbnail.run" },
meta: { documentId, generated: false, mimeType: document.mimeType },
});
// An Office or text document has no preview by design; anything else
// was meant to get one.
if (result.reason !== "unsupported-type") warn(result.reason);
// Kept, so the boot-time backfill stops queueing a document whose render
// cannot succeed. An unsupported type is never queued by it; a host
// without the canvas build never runs it.
if (
result.reason !== "unsupported-type" &&
result.reason !== "native-canvas-unsupported"
) {
await recordThumbnailFailure(userId, document.id, result.reason);
}
return;
}
const { jpeg, width, height } = result.thumbnail;
// Upsert (not create) so a racing re-enqueue cannot violate the 1:1 unique
// index; the last render wins. `userId` comes from the scoped load, never a
// body — no mass assignment.
await prisma.documentThumbnail.upsert({
where: { documentId: document.id },
create: {
documentId: document.id,
userId,
thumbnailEncrypted: encryptThumbnail(jpeg),
width,
height,
byteSize: jpeg.byteLength,
sourceMime: document.mimeType,
},
update: {
thumbnailEncrypted: encryptThumbnail(jpeg),
width,
height,
byteSize: jpeg.byteLength,
sourceMime: document.mimeType,
},
});
// account-deletion: the one document this job was handed.
await prisma.documentThumbnailFailure.deleteMany({
where: { documentId: document.id },
});
annotate({
action: { name: "documents.thumbnail.run" },
meta: { documentId, generated: true, byteSize: jpeg.byteLength },
});
}
/**
* Note that a render failed, counting the attempts since the last success
* (`DocumentThumbnailFailure`). `userId` comes from the scoped load.
*/
async function recordThumbnailFailure(
userId: string,
documentId: string,
reason: string,
): Promise<void> {
const failedAt = new Date();
await prisma.documentThumbnailFailure.upsert({
where: { documentId },
create: { documentId, userId, reason, attempts: 1, failedAt },
update: { reason, attempts: { increment: 1 }, failedAt },
});
}
/**
* Enqueue a per-document thumbnail job. Coalesced by `singletonKey` per
* document so a duplicate/idempotent re-upload cannot pile up parallel runs.
* Fire-and-forget — a missing boss (worker not up) is a no-op, and a
* `boss.send` failure (a transient DB hiccup) is swallowed to a no-op too, so
* enqueue can NEVER fail an already-stored upload. A dropped enqueue is
* recoverable via the corpus backfill.
*/
export async function enqueueDocumentThumbnail(
userId: string,
documentId: string,
): Promise<{ enqueued: boolean }> {
const boss = getGlobalBoss();
if (!boss) return { enqueued: false };
const payload: DocumentThumbnailPayload = {
userId,
documentId,
enqueuedAt: new Date().toISOString(),
};
try {
const jobId = await boss.send(DOCUMENT_THUMBNAIL_QUEUE, payload, {
retryLimit: 2,
retryDelay: 30,
retryBackoff: true,
singletonKey: `document-thumbnail|${documentId}`,
});
return { enqueued: Boolean(jobId) };
} catch (err) {
annotate({
action: { name: "documents.thumbnail.enqueueFailed" },
meta: { documentId, reason: err instanceof Error ? err.name : "unknown" },
});
logCaught("documents.thumbnail.enqueue_failed", err);
return { enqueued: false };
}
}