fix(documents): stop queueing a thumbnail that cannot be rendered on every boot

A document whose preview render fails for good (a PDF whose first page
renders blank) was queued by the boot-time backfill, refused, and queued
again at the next boot, without end.

The thumbnail job now keeps the failure with the document
(document_thumbnail_failures, migration 0374): the reason, the attempts
since the last success and when the newest failed. The backfill, both the
boot discovery and the per-account pass, leaves a document out after one
deterministic refusal (raster-failed, empty-render, pixel-cap) or three of
any other kind, until the document changes. A direct enqueue still renders
it, and a preview that is made clears the record.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 15:56:56 +02:00
1 parent f18e6ed34b
commit 3e08fd0e68
8 files changed
+405 -26

No files matched your search

+1
View File
@@ -153,6 +153,7 @@ export const WIPE_MODELS = [
// ── Documents ───────────────────────────────────────────────────────────
"DocumentContentIndex",
"DocumentThumbnail",
"DocumentThumbnailFailure",
"DocumentConditionLink",
// The memory of which imported documents the person deleted (#1038). The
// documents themselves go below; keeping the memory would make a fresh
+2
View File
@@ -620,6 +620,8 @@ export const DERIVED_MODELS: Readonly<Record<string, string>> = {
"A search index over `InboundDocument` text; rebuilt by the indexer.",
DocumentThumbnail:
"Rendered from the stored document bytes on demand. Those bytes travel in a disaster-recovery payload, so the picture rebuilds itself on first view; a portable export carries metadata only, and there the thumbnail has nothing to rebuild from — but neither does the document, so nothing is lost that the export did not already disclose.",
DocumentThumbnailFailure:
"Why a document's preview could not be rendered on this host, kept so the boot backfill stops retrying it. The receiving host renders the restored document itself, and its own failure, if any, writes the row again; carrying this one would stop a host whose renderer can draw the page from ever trying.",
ArrivalReaction:
"Derived from what changed since the last visit; recomputed per visit.",
DismissedPriorityItem:
@@ -9,25 +9,37 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
* never has a preview stays at info.
*/
const { evt, findFirst, upsert, generateThumbnail, withBackgroundEvent } =
vi.hoisted(() => {
const evt = { addMeta: vi.fn(), elevateLevel: vi.fn() };
return {
evt,
findFirst: vi.fn(),
upsert: vi.fn(),
generateThumbnail: vi.fn(),
withBackgroundEvent: vi.fn(
async (_name: string, fn: (e: typeof evt) => Promise<unknown>) =>
fn(evt),
),
};
});
const {
evt,
findFirst,
upsert,
failureUpsert,
failureDelete,
generateThumbnail,
withBackgroundEvent,
} = vi.hoisted(() => {
const evt = { addMeta: vi.fn(), elevateLevel: vi.fn() };
return {
evt,
findFirst: vi.fn(),
upsert: vi.fn(),
failureUpsert: vi.fn(),
failureDelete: vi.fn(),
generateThumbnail: vi.fn(),
withBackgroundEvent: vi.fn(
async (_name: string, fn: (e: typeof evt) => Promise<unknown>) => fn(evt),
),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
inboundDocument: { findFirst },
documentThumbnail: { upsert },
documentThumbnailFailure: {
upsert: failureUpsert,
deleteMany: failureDelete,
},
},
}));
vi.mock("@/lib/documents/store", () => ({
@@ -95,4 +107,74 @@ describe("runDocumentThumbnail wide event", () => {
expect(upsert).toHaveBeenCalledOnce();
expect(evt.elevateLevel).not.toHaveBeenCalled();
});
it("keeps a render failure so the backfill can stop queueing the document", async () => {
generateThumbnail.mockResolvedValue({ ok: false, reason: "raster-failed" });
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
expect(failureUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { documentId: "doc-1" },
create: expect.objectContaining({
userId: "user-1",
reason: "raster-failed",
attempts: 1,
}),
update: expect.objectContaining({ attempts: { increment: 1 } }),
}),
);
});
it("keeps no failure for a type that never has a preview, and clears one when a preview is made", async () => {
findFirst.mockResolvedValue({ ...DOC, mimeType: "text/plain" });
generateThumbnail.mockResolvedValue({
ok: false,
reason: "unsupported-type",
});
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
expect(failureUpsert).not.toHaveBeenCalled();
findFirst.mockResolvedValue(DOC);
generateThumbnail.mockResolvedValue({
ok: true,
thumbnail: { jpeg: Buffer.from([0xff, 0xd8]), width: 2, height: 3 },
});
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
expect(failureDelete).toHaveBeenCalledWith({
where: { documentId: "doc-1" },
});
});
});
describe("thumbnailRetryDue", () => {
const at = new Date("2026-10-01T00:00:00Z");
const before = new Date("2026-09-30T00:00:00Z");
const after = new Date("2026-10-02T00:00:00Z");
it("retries a document with no failure, and one that changed since", async () => {
const { thumbnailRetryDue } =
await import("../document-thumbnail-backfill");
expect(thumbnailRetryDue(null, before)).toBe(true);
expect(
thumbnailRetryDue(
{ reason: "raster-failed", attempts: 1, failedAt: at },
after,
),
).toBe(true);
});
it("settles a deterministic refusal after one try and a throw after three", async () => {
const { thumbnailRetryDue } =
await import("../document-thumbnail-backfill");
for (const reason of ["raster-failed", "empty-render", "pixel-cap"]) {
expect(
thumbnailRetryDue({ reason, attempts: 1, failedAt: at }, before),
).toBe(false);
}
expect(
thumbnailRetryDue({ reason: "error", attempts: 2, failedAt: at }, before),
).toBe(true);
expect(
thumbnailRetryDue({ reason: "error", attempts: 3, failedAt: at }, before),
).toBe(false);
});
});
+70 -4
View File
@@ -15,6 +15,11 @@
* drops out of the set the moment its thumbnail row lands, so the walk
* converges and a re-run picks up where the cap left off.
*
* A document whose render failed for good drops out too
* (`DocumentThumbnailFailure`, see {@link thumbnailRetryDue}): a PDF whose
* first page renders blank never gets a thumbnail, and without the record it
* was queued again at every boot, without end.
*
* The queue MUST be registered in the maintenance registrar
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
*/
@@ -45,6 +50,39 @@ const THUMBNAILABLE_MIMES = [
"application/pdf",
] as const;
/**
* Render refusals that the same bytes will always get again: the first page
* renders blank, the render comes out empty, the image is past the pixel cap.
* One attempt settles them.
*/
const FINAL_THUMBNAIL_FAILURES = [
"raster-failed",
"empty-render",
"pixel-cap",
] as const;
/** Attempts any other failure (a throw, a key that did not open) gets. */
export const MAX_THUMBNAIL_ATTEMPTS = 3;
/**
* Whether a document whose render failed is due another try: never after a
* final refusal or the last attempt, unless the document changed since. The
* boot discovery's SQL states the same rule.
*/
export function thumbnailRetryDue(
failure: { reason: string; attempts: number; failedAt: Date } | null,
documentUpdatedAt: Date,
): boolean {
if (!failure) return true;
if (documentUpdatedAt > failure.failedAt) return true;
if (
(FINAL_THUMBNAIL_FAILURES as readonly string[]).includes(failure.reason)
) {
return false;
}
return failure.attempts < MAX_THUMBNAIL_ATTEMPTS;
}
export interface ThumbnailBackfillPayload {
userId: string;
enqueuedAt?: string;
@@ -67,22 +105,37 @@ export async function runThumbnailBackfillForUser(
for (;;) {
if (enqueued >= MAX_ENQUEUES_PER_RUN) break;
const rows: { id: string }[] = await prisma.inboundDocument.findMany({
const rows: Array<{
id: string;
updatedAt: Date;
thumbnailFailure: {
reason: string;
attempts: number;
failedAt: Date;
} | null;
}> = await prisma.inboundDocument.findMany({
where: {
userId,
deletedAt: null,
mimeType: { in: [...THUMBNAILABLE_MIMES] },
thumbnail: { is: null },
},
select: { id: true },
select: {
id: true,
updatedAt: true,
thumbnailFailure: {
select: { reason: true, attempts: true, failedAt: true },
},
},
orderBy: { id: "asc" },
take: PAGE_SIZE,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
if (rows.length === 0) break;
for (const { id } of rows) {
for (const { id, updatedAt, thumbnailFailure } of rows) {
if (enqueued >= MAX_ENQUEUES_PER_RUN) break;
if (!thumbnailRetryDue(thumbnailFailure, updatedAt)) continue;
const { enqueued: ok } = await enqueueDocumentThumbnail(userId, id);
if (ok) enqueued += 1;
}
@@ -119,13 +172,26 @@ export async function enqueueBootTimeThumbnailBackfill(): Promise<{
// `(user_id, mime_type) WHERE deleted_at IS NULL` bounds the document-side
// scan; `document_thumbnails.document_id` is already UNIQUE (the 1:1
// relation) so the `t.id IS NULL` anti-join is index-driven.
//
// A document whose render failed for good is left out by the rule
// `thumbnailRetryDue` states, so an account whose only missing previews
// cannot be made is not queued at every boot.
const rows = await prisma.$queryRaw<{ user_id: string }[]>`
SELECT DISTINCT d.user_id AS user_id
FROM inbound_documents d
LEFT JOIN document_thumbnails t ON t.document_id = d.id
LEFT JOIN document_thumbnail_failures f ON f.document_id = d.id
WHERE d.deleted_at IS NULL
AND d.mime_type IN (${Prisma.join([...THUMBNAILABLE_MIMES])})
AND t.id IS NULL`;
AND t.id IS NULL
AND (
f.document_id IS NULL
OR d.updated_at > f.failed_at
OR (
f.reason NOT IN (${Prisma.join([...FINAL_THUMBNAIL_FAILURES])})
AND f.attempts < ${MAX_THUMBNAIL_ATTEMPTS}
)
)`;
let enqueued = 0;
for (const { user_id: userId } of rows) {
+31
View File
@@ -105,6 +105,7 @@ async function renderThumbnail(
meta: { documentId },
});
warn("decrypt-failed");
await recordThumbnailFailure(userId, document.id, "decrypt-failed");
return;
}
@@ -117,6 +118,15 @@ async function renderThumbnail(
// 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;
}
@@ -144,12 +154,33 @@ async function renderThumbnail(
},
});
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.