From 6b820fa3c5ebc0c1b6444bffa26889a258bb8208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 11:05:43 +0100 Subject: [PATCH] fix(rollups): defer the dense fold when the backfill check cannot read the queue The check whether an account's full rollup backfill is queued or running answered false on any error, so a failed read let the dense fold delete raw samples the backfill still had to read. A missing queue table still answers false, since a web-only deployment has no backfill; any other failure now counts as pending and is annotated. A backfill that failed for good is still not pending, as the comment now says. --- src/lib/rollups/full-backfill.ts | 27 ++++++++++++++++--- .../integration/rollup-full-backfill.test.ts | 21 +++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/lib/rollups/full-backfill.ts b/src/lib/rollups/full-backfill.ts index 1d42b7a33..9894f3402 100644 --- a/src/lib/rollups/full-backfill.ts +++ b/src/lib/rollups/full-backfill.ts @@ -24,6 +24,7 @@ import { activeAccountSql } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { annotate } from "@/lib/logging/context"; import { applyStatementBudget, recomputeUserRollups, @@ -199,13 +200,24 @@ export async function sendRollupFullBackfillContinuation( * leaves such an account alone (`ConsolidationOptions.deferUser`): it deletes * the raw samples a fold still has to read, and a year the full backfill * reached after the dense fold would take its day figures from hourly means. - * False when the queue schema cannot be read, as a web-only deployment has - * no full backfill either. + * + * False when there is no queue table (a web-only deployment has no full + * backfill either). Any other failure to read the queue counts as pending: + * the dense fold's deletion cannot be undone, and deferring an account costs + * one night. The failure is annotated on the caller's event. + * + * A full fold that failed for good (retries spent) is not pending, and the + * dense fold goes ahead on that account; the system job alert reports the + * failed backfill, and the next boot's discovery enqueues it again. */ export async function isRollupFullBackfillPending( userId: string, ): Promise { try { + const [queue] = await prisma.$queryRaw>` + SELECT to_regclass('pgboss.job') IS NOT NULL AS present + `; + if (!queue?.present) return false; const rows = await prisma.$queryRaw>` SELECT count(*)::int AS n FROM pgboss.job @@ -214,8 +226,15 @@ export async function isRollupFullBackfillPending( AND data->>'userId' = ${userId} `; return (rows[0]?.n ?? 0) > 0; - } catch { - return false; + } catch (err) { + annotate({ + meta: { + rollup_backfill_pending_check_failed: true, + rollup_backfill_pending_check_error: + err instanceof Error ? err.message.slice(0, 200) : String(err), + }, + }); + return true; } } diff --git a/tests/integration/rollup-full-backfill.test.ts b/tests/integration/rollup-full-backfill.test.ts index cdf0742fa..a41182abe 100644 --- a/tests/integration/rollup-full-backfill.test.ts +++ b/tests/integration/rollup-full-backfill.test.ts @@ -344,4 +344,25 @@ describe("isRollupFullBackfillPending", () => { await boss.complete(ROLLUP_FULL_BACKFILL_QUEUE, job.id); expect(await isRollupFullBackfillPending("user-a")).toBe(false); }); + + it("counts a queue it cannot read as pending, so the dense fold waits", async () => { + // The table is there but the read fails. + await prisma.$executeRawUnsafe( + `ALTER TABLE pgboss.job RENAME COLUMN data TO data_unreadable`, + ); + try { + expect(await isRollupFullBackfillPending("user-a")).toBe(true); + } finally { + await prisma.$executeRawUnsafe( + `ALTER TABLE pgboss.job RENAME COLUMN data_unreadable TO data`, + ); + } + }); +}); + +describe("isRollupFullBackfillPending without a queue", () => { + it("is false where there is no queue table at all", async () => { + await prisma.$executeRawUnsafe(`DROP SCHEMA IF EXISTS pgboss CASCADE`); + expect(await isRollupFullBackfillPending("user-a")).toBe(false); + }); });