mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
f18e6ed34b
commit
3e08fd0e68
8 files changed
+405
-26
No files matched your search
@@ -0,0 +1,24 @@
|
||||
-- Why a document has no preview thumbnail. The boot-time backfill skips a
|
||||
-- document whose render failed for good, until the document changes.
|
||||
CREATE TABLE "document_thumbnail_failures" (
|
||||
"document_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 1,
|
||||
"failed_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "document_thumbnail_failures_pkey" PRIMARY KEY ("document_id")
|
||||
);
|
||||
|
||||
CREATE INDEX "document_thumbnail_failures_user_id_idx"
|
||||
ON "document_thumbnail_failures"("user_id");
|
||||
|
||||
ALTER TABLE "document_thumbnail_failures"
|
||||
ADD CONSTRAINT "document_thumbnail_failures_document_id_fkey"
|
||||
FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "document_thumbnail_failures"
|
||||
ADD CONSTRAINT "document_thumbnail_failures_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+38
-8
@@ -766,6 +766,7 @@ model User {
|
||||
backupRestoreJobs BackupRestoreJob[]
|
||||
offhostBackupState OffhostBackupState?
|
||||
backupPassAttempts BackupPassAttempt[]
|
||||
documentThumbnailFailures DocumentThumbnailFailure[]
|
||||
achievements UserAchievement[]
|
||||
recommendationFeedback RecommendationFeedback[]
|
||||
idempotencyKeys IdempotencyKey[]
|
||||
@@ -8396,28 +8397,29 @@ model InboundDocument {
|
||||
/// explicitly reads it with AI or generates its summary.
|
||||
aiReadDeferred Boolean @default(false) @map("ai_read_deferred")
|
||||
|
||||
facts ExtractedFact[]
|
||||
conditionLinks DocumentConditionLink[]
|
||||
facts ExtractedFact[]
|
||||
conditionLinks DocumentConditionLink[]
|
||||
/// 1:1 blind content-search index (encrypted extracted text + HMAC token
|
||||
/// array). Absent until the document is indexed; cascades on delete.
|
||||
contentIndex DocumentContentIndex?
|
||||
contentIndex DocumentContentIndex?
|
||||
/// 1:1 encrypted preview thumbnail (small JPEG, AES-256-GCM at rest).
|
||||
/// Absent until the background thumbnail job runs; a side table (not a
|
||||
/// column) so the list/detail `omit: { contentEncrypted }` queries never
|
||||
/// drag the blob into the SELECT. Cascades on delete.
|
||||
thumbnail DocumentThumbnail?
|
||||
thumbnail DocumentThumbnail?
|
||||
thumbnailFailure DocumentThumbnailFailure?
|
||||
/// v1.29.x (S7) — coach-conversation attachments (m:n via
|
||||
/// `CoachConversationDocument`). Deleting this document cascades its join rows
|
||||
/// away (its text leaves every conversation's context) but never the
|
||||
/// conversation itself — a fenced thread survives via its sticky flag.
|
||||
chatAttachments CoachConversationDocument[]
|
||||
chatAttachments CoachConversationDocument[]
|
||||
/// share-link memberships (m:n via `ClinicianShareLinkDocument`).
|
||||
/// Cascades: deleting this document removes it from every share it was on.
|
||||
shareLinks ClinicianShareLinkDocument[]
|
||||
shareLinks ClinicianShareLinkDocument[]
|
||||
/// Further import source keys that resolved to this document by content.
|
||||
sourceAliases DocumentSourceAlias[]
|
||||
sourceAliases DocumentSourceAlias[]
|
||||
/// Background AI reads of this document (v1.40). Transient; cascades.
|
||||
aiRuns DocumentAiRun[]
|
||||
aiRuns DocumentAiRun[]
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -8709,6 +8711,34 @@ model DocumentThumbnail {
|
||||
@@map("document_thumbnails")
|
||||
}
|
||||
|
||||
/// Why a document has no preview thumbnail, kept so the boot-time backfill
|
||||
/// stops queueing a document whose render cannot succeed. A PDF whose first
|
||||
/// page renders blank was queued, rendered, refused and queued again at
|
||||
/// every boot, without end. A deterministic refusal (`raster-failed`,
|
||||
/// `empty-render`, `pixel-cap`) is final after one attempt; any other reason
|
||||
/// gets three. Either way the document is tried again once it changes
|
||||
/// (`updatedAt` past `failedAt`), and a direct enqueue (an upload) still
|
||||
/// renders it. A preview that is made deletes the row.
|
||||
/// Consumers: `runThumbnailBackfillForUser()` and
|
||||
/// `enqueueBootTimeThumbnailBackfill()` in
|
||||
/// `src/lib/jobs/document-thumbnail-backfill.ts`.
|
||||
model DocumentThumbnailFailure {
|
||||
documentId String @id @map("document_id")
|
||||
userId String @map("user_id")
|
||||
/// The renderer's reason (`ThumbnailSkipReason`), or `decrypt-failed`.
|
||||
reason String
|
||||
/// Renders that failed since the last success or change.
|
||||
attempts Int @default(1)
|
||||
/// When the newest of them failed.
|
||||
failedAt DateTime @map("failed_at")
|
||||
|
||||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("document_thumbnail_failures")
|
||||
}
|
||||
|
||||
// ─── Nutrient intake (v1.28) ───────────────────────────────────────
|
||||
//
|
||||
// Micronutrient daily totals synced from Apple Health — vitamins,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* A document whose preview cannot be rendered is not queued again on every
|
||||
* boot.
|
||||
*
|
||||
* What this pins. The boot-time backfill queued every thumbnailable document
|
||||
* without a thumbnail, and a PDF whose first page renders blank never gets
|
||||
* one: it was queued, rendered, refused, and queued again at the next boot,
|
||||
* forever. The failure is now kept with the document, the backfill leaves a
|
||||
* document that failed for good alone, and a document that changed after the
|
||||
* failure is tried once more. A preview that does get made clears it.
|
||||
*
|
||||
* Both ends against the real schema: the job writes the failure, the boot
|
||||
* discovery and the per-account pass read it. Only the renderer and the queue
|
||||
* are stand-ins, so the outcome of a render can be chosen.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const generateThumbnail = vi.hoisted(() => vi.fn());
|
||||
const sends = vi.hoisted(() => [] as Array<{ queue: string; data: unknown }>);
|
||||
|
||||
vi.mock("@/lib/documents/thumbnail", () => ({ generateThumbnail }));
|
||||
vi.mock("@/lib/documents/native-canvas-support", () => ({
|
||||
nativeCanvasSupported: () => true,
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => ({
|
||||
send: async (queue: string, data: unknown) => {
|
||||
sends.push({ queue, data });
|
||||
return `job-${sends.length}`;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { encryptDocumentContent } from "@/lib/documents/store";
|
||||
import { runDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
|
||||
import {
|
||||
enqueueBootTimeThumbnailBackfill,
|
||||
runThumbnailBackfillForUser,
|
||||
} from "@/lib/jobs/document-thumbnail-backfill";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER_ID = "thumbnail-backfill-owner";
|
||||
|
||||
async function seedPdf(id: string) {
|
||||
const { content, codec } = encryptDocumentContent(
|
||||
Buffer.from("%PDF-1.7 a page that renders blank"),
|
||||
);
|
||||
await prisma.inboundDocument.create({
|
||||
data: {
|
||||
id,
|
||||
userId: USER_ID,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 32,
|
||||
contentEncrypted: content,
|
||||
contentCodec: codec,
|
||||
title: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function queuedDocuments(): string[] {
|
||||
return sends
|
||||
.filter((s) => s.queue === "document-thumbnail")
|
||||
.map((s) => (s.data as { documentId: string }).documentId);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
sends.length = 0;
|
||||
generateThumbnail.mockReset();
|
||||
await prisma.user.create({
|
||||
data: { id: USER_ID, username: "thumbnail-backfill-owner" },
|
||||
});
|
||||
});
|
||||
|
||||
describe("thumbnail backfill and a render that fails for good", () => {
|
||||
it("leaves a document whose render failed for good out of every later backfill", async () => {
|
||||
await seedPdf("doc-blank");
|
||||
await seedPdf("doc-fine");
|
||||
generateThumbnail.mockResolvedValue({ ok: false, reason: "raster-failed" });
|
||||
await runDocumentThumbnail({ userId: USER_ID, documentId: "doc-blank" });
|
||||
|
||||
sends.length = 0;
|
||||
await runThumbnailBackfillForUser(USER_ID);
|
||||
expect(queuedDocuments()).toEqual(["doc-fine"]);
|
||||
|
||||
// The boot discovery still finds the account, for the other document,
|
||||
// and stops finding it once that one has its preview.
|
||||
sends.length = 0;
|
||||
expect((await enqueueBootTimeThumbnailBackfill()).enqueued).toBe(1);
|
||||
generateThumbnail.mockResolvedValue({
|
||||
ok: true,
|
||||
thumbnail: { jpeg: Buffer.from([0xff, 0xd8, 0xff]), width: 2, height: 3 },
|
||||
});
|
||||
await runDocumentThumbnail({ userId: USER_ID, documentId: "doc-fine" });
|
||||
sends.length = 0;
|
||||
expect((await enqueueBootTimeThumbnailBackfill()).enqueued).toBe(0);
|
||||
expect(await runThumbnailBackfillForUser(USER_ID)).toEqual({ enqueued: 0 });
|
||||
});
|
||||
|
||||
it("tries a failed document again once it has changed, and forgets the failure when a preview is made", async () => {
|
||||
await seedPdf("doc-blank");
|
||||
generateThumbnail.mockResolvedValue({ ok: false, reason: "raster-failed" });
|
||||
await runDocumentThumbnail({ userId: USER_ID, documentId: "doc-blank" });
|
||||
sends.length = 0;
|
||||
expect(await runThumbnailBackfillForUser(USER_ID)).toEqual({ enqueued: 0 });
|
||||
|
||||
// A later edit is a change: the document is tried once more.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id: "doc-blank" },
|
||||
data: { title: "renamed" },
|
||||
});
|
||||
expect(await runThumbnailBackfillForUser(USER_ID)).toEqual({ enqueued: 1 });
|
||||
sends.length = 0;
|
||||
expect((await enqueueBootTimeThumbnailBackfill()).enqueued).toBe(1);
|
||||
|
||||
generateThumbnail.mockResolvedValue({
|
||||
ok: true,
|
||||
thumbnail: { jpeg: Buffer.from([0xff, 0xd8, 0xff]), width: 2, height: 3 },
|
||||
});
|
||||
await runDocumentThumbnail({ userId: USER_ID, documentId: "doc-blank" });
|
||||
expect(
|
||||
await prisma.documentThumbnailFailure.count({
|
||||
where: { documentId: "doc-blank" },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("gives a render that failed by a throw three tries, not one", async () => {
|
||||
await seedPdf("doc-flaky");
|
||||
generateThumbnail.mockResolvedValue({ ok: false, reason: "error" });
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
sends.length = 0;
|
||||
expect(await runThumbnailBackfillForUser(USER_ID)).toEqual({
|
||||
enqueued: 1,
|
||||
});
|
||||
await runDocumentThumbnail({ userId: USER_ID, documentId: "doc-flaky" });
|
||||
}
|
||||
expect(await runThumbnailBackfillForUser(USER_ID)).toEqual({ enqueued: 0 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user