diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 0c386bbf8..30ee06eb4 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -494,6 +494,77 @@ describe(" — score column", () => { // collapses (the 120 px sm geometry rides an inline style). expect(html).toContain("width:120px"); }); + + // A null score on an account that already carries score inputs is a + // warm-up state (the rollup tier is mid-fold), not a data gap — the + // copy must say "computing", never "not enough data". + it("null score WITH existing score inputs renders the computing label, not the data-gap one", () => { + const html = render( + baseSnapshot({ + healthScore: null, + tiles: { + summaries: { WEIGHT: summary({ count: 12, latest: 80 }) }, + lastSeenByType: { + WEIGHT: { lastSeenAt: isoHoursFromNow(-2), daysAgo: 0 }, + }, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain('data-provisional="true"'); + expect(html).toContain("Score wird berechnet"); + expect(html).not.toContain("Noch nicht genug Daten"); + }); + + it("active medications alone count as a score input (compliance pillar)", () => { + const html = render( + baseSnapshot({ + healthScore: null, + medsToday: medsToday({ activeCount: 1 }), + }), + ); + expect(html).toContain("Score wird berechnet"); + expect(html).not.toContain("Noch nicht genug Daten"); + }); + + it("keeps the genuine data-gap label when no score input exists at all", () => { + // Empty summaries, no mood entries, no active medications — the + // baseSnapshot. A non-score-pillar summary (steps) must not flip + // the copy either. + const html = render( + baseSnapshot({ + healthScore: null, + tiles: { + summaries: { ACTIVITY_STEPS: summary({ count: 30, latest: 9000 }) }, + lastSeenByType: {}, + mood: { summary: null, entries: [] }, + }, + }), + ); + expect(html).toContain("Noch nicht genug Daten"); + expect(html).not.toContain("Score wird berechnet"); + }); +}); + +describe(" — typographic hierarchy", () => { + it("the greeting leads: larger + heavier than the verdict line", () => { + const html = render(baseSnapshot()); + const greeting = html.match( + /]*data-slot="dashboard-hero-greeting"[^>]*>/, + ); + expect(greeting).not.toBeNull(); + expect(greeting![0]).toContain("text-lg"); + expect(greeting![0]).toContain("font-semibold"); + expect(greeting![0]).not.toContain("text-muted-foreground"); + const verdict = html.match( + /]*data-slot="dashboard-hero-verdict"[^>]*>/, + ); + expect(verdict).not.toBeNull(); + // The verdict stays one step down — base size, medium weight. + expect(verdict![0]).toContain("text-base"); + expect(verdict![0]).toContain("font-medium"); + expect(verdict![0]).not.toContain("font-semibold"); + }); }); describe(" — source pins", () => { diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index 3d917740c..6daeb2566 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -11,11 +11,18 @@ * * - left column: greeting line (absorbed from the old header * paragraph — the snapshot's server-computed `greetingHour` keeps - * the daypart free of any client `Intl` work), the verdict sentence - * with its single CTA button, and the dose row; + * the daypart free of any client `Intl` work; typographically the + * band's anchor, larger and heavier than the verdict so the + * personalised line is not drowned by the content below it), the + * verdict sentence with its single CTA button, and the dose row; * - right column: the shared `` at `sm` geometry. A null * score renders the ring's provisional state at the identical - * 120 px footprint, so the column never collapses. + * 120 px footprint, so the column never collapses. The provisional + * label is honest about WHY the score is null: when the snapshot + * already carries score inputs (weight / BP summaries, mood + * entries, active medications) the rollup tier is merely mid-fold + * and the label reads "computing"; only an account with no score + * inputs at all gets "not enough data". * * Defensive contract (cached snapshot): `medsToday.nextDueAt` in the * past with `nextDueOverdue: false` means the slot's anchor passed @@ -167,6 +174,22 @@ export function DashboardHero({ const cta = verdict.cta; const ctaLabelKey = CTA_LABEL_KEY[verdict.variant]; + // ── Provisional score copy ────────────────────────────────────────── + // The score's four pillars are weight, BP, mood, and medication + // compliance. When ANY of them already carries data, a null score is + // a warm-up state (the rollup tier hasn't folded the buckets the + // warm-phase score rides on yet) — telling an account with weeks of + // readings "not enough data" would be a lie. Only an account with no + // score inputs at all keeps the genuine empty-state copy. Cheap + // heuristic over fields the snapshot already carries; no extra reads. + const summaries = snapshot.tiles.summaries; + const hasScoreInputs = + (summaries.WEIGHT?.count ?? 0) > 0 || + (summaries.BLOOD_PRESSURE_SYS?.count ?? 0) > 0 || + (summaries.BLOOD_PRESSURE_DIA?.count ?? 0) > 0 || + snapshot.tiles.mood.entries.length > 0 || + snapshot.medsToday.activeCount > 0; + // ── Dose row ──────────────────────────────────────────────────────── const meds = snapshot.medsToday; const now = new Date(); @@ -206,9 +229,15 @@ export function DashboardHero({ >
+ {/* Greeting leads the typographic hierarchy: larger + heavier + than the verdict so the personalised line reads first. + The verdict keeps its medium weight one step down — still + the content lead, clearly subordinate to the greeting. + Both stay on the existing foreground token; the band adds + no new colour. */}

{welcomeText}

@@ -216,7 +245,7 @@ export function DashboardHero({

{sentence}

@@ -276,7 +305,9 @@ export function DashboardHero({ label={ snapshot.healthScore ? t("dashboard.hero.scoreLabel") - : t("dashboard.hero.scoreProvisional") + : hasScoreInputs + ? t("dashboard.hero.scoreComputing") + : t("dashboard.hero.scoreProvisional") } />
diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index ef9da94e1..74149aba8 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -216,6 +216,110 @@ describe("buildDashboardSnapshot — two-phase null extras", () => { }); }); +describe("buildDashboardSnapshot — per-type thick-phase gate", () => { + // The thick phase gates ONLY on the types it reads through the + // rollup tier (WEIGHT + BLOOD_PRESSURE_SYS/_DIA). An uncovered + // UNRELATED type (a fresh wearable reading whose async fold hasn't + // landed) must not null extras/healthScore — the all-types + // `isFullyCovered` AND was the bug, not the per-type misses. + it("an uncovered UNRELATED type no longer nulls extras + healthScore", async () => { + probeRollupCoverage.mockResolvedValue( + new Map([ + ["WEIGHT", true], + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + // Fresh WHOOP recovery reading, no DAY bucket yet. + ["RECOVERY_SCORE", false], + ]), + ); + // Pin that the builder no longer consults the all-types helper: + // even an explicit `false` from it must not cold the phase. + isFullyCovered.mockReturnValue(false); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: { pct: 70 }, + last30Days: { pct: 80 }, + allTime: { pct: 75 }, + priorMonth: { pct: 60 }, + priorYear: { pct: 50 }, + gradedScore: 84, + }); + computeUserHealthScoreFastPath.mockResolvedValue({ + score: 71, + band: "yellow", + delta: 3, + components: {}, + }); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.extras).not.toBeNull(); + expect(snap.extras!.bpInTargetPct).toBe(80); + expect(snap.healthScore).toEqual({ score: 71, band: "yellow", delta: 3 }); + expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(1); + expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1); + }); + + it("a fresh account with zero coverage entries stays null", async () => { + probeRollupCoverage.mockResolvedValue(new Map()); + isFullyCovered.mockReturnValue(false); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.extras).toBeNull(); + expect(snap.healthScore).toBeNull(); + expect(computeBpInTargetFastPath).not.toHaveBeenCalled(); + expect(computeUserHealthScoreFastPath).not.toHaveBeenCalled(); + }); + + it("a gated type ABSENT from the map (never logged) keeps the phase warm", async () => { + // BP never logged at all — the BP helper's live fallback is an + // empty read, so absence must not cold the phase. + probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]])); + isFullyCovered.mockReturnValue(false); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: null, + last30Days: null, + allTime: null, + priorMonth: null, + priorYear: null, + gradedScore: null, + }); + computeUserHealthScoreFastPath.mockResolvedValue(null); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + expect(snap.extras).not.toBeNull(); + }); + + it.each([ + ["WEIGHT"], + ["BLOOD_PRESSURE_SYS"], + ["BLOOD_PRESSURE_DIA"], + ])( + "an uncovered %s (rows without buckets) keeps the phase cold", + async (uncoveredType) => { + const coverage = new Map([ + ["WEIGHT", true], + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]); + coverage.set(uncoveredType, false); + probeRollupCoverage.mockResolvedValue(coverage); + isFullyCovered.mockReturnValue(false); + + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + + // Rows without buckets on a gated type would route the helper + // onto its multi-second live fallback — the snapshot must not + // wait on it. Null is acceptable here; the all-types poison was + // the bug. + expect(snap.extras).toBeNull(); + expect(snap.healthScore).toBeNull(); + expect(computeBpInTargetFastPath).not.toHaveBeenCalled(); + }, + ); +}); + describe("buildDashboardSnapshot — medsToday (fast phase)", () => { it("rides the fast phase and is present even on a rollup-coverage miss", async () => { probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]])); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index bb633d37d..376dcb7e5 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -49,7 +49,6 @@ import { getBpTargets } from "@/lib/analytics/bp-targets"; import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path"; import { probeRollupCoverage, - isFullyCovered, type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; import { @@ -414,11 +413,12 @@ async function buildMoodBlock( /** * Thick slice — BD-Zielbereich + per-context glucose + health score. - * Only called by the builder when the rollup tier is warm - * (`isFullyCovered`); the BP fast-path then stays on the sub-second - * rollup branch and never drops into the multi-second live fallback - * that would make the whole strip wait. On a coverage miss the builder - * skips this entirely and emits `extras: null` + `healthScore: null` + * Only called by the builder when the rollup tier is warm for the + * types this slice actually reads (`isThickPhaseWarm`); the BP + * fast-path then stays on the sub-second rollup branch and never drops + * into the multi-second live fallback that would make the whole strip + * wait. On a coverage miss for one of those types the builder skips + * this entirely and emits `extras: null` + `healthScore: null` * (per-tile shimmer until the boot backfill converges). * * The health score rides this warm phase because it reuses the BP @@ -685,6 +685,37 @@ function buildLayoutCatalogue( return out.sort((a, b) => a.order - b.order); } +/** + * Per-type warm gate for the thick phase (extras + healthScore), + * replacing the all-types `isFullyCovered` AND. The all-types gate + * nulled the whole thick phase whenever ANY unrelated type lacked a + * DAY bucket — one fresh wearable reading whose async fold hadn't + * landed yet zeroed the hero score, while the insights analytics + * route (which calls the self-gating helpers unconditionally) kept + * showing a score for the same account. Mirrors the v1.4.38.8 + * per-type pattern already inside `computeBpInTargetFastPath` and + * `computeUserHealthScoreFastPath`: gate only on the types the thick + * phase actually reads through the rollup tier — WEIGHT (score weight + * pillar) + BLOOD_PRESSURE_SYS / _DIA (BP windows + BP pillar). + * + * `!== false` rather than `=== true`: a type ABSENT from the map has + * zero measurements, so the helpers' own live fallbacks are trivially + * cheap (empty reads) and absence must not cold the phase. A type + * present-but-`false` has live rows without buckets — for the three + * gated types that is exactly the multi-second live fallback the + * snapshot must never wait on, so the phase stays `null` until the + * backfill converges. An empty map is a fresh account with no + * measurements at all — nothing to compute, stay `null`. + */ +function isThickPhaseWarm(coverage: RollupCoverageMap): boolean { + if (coverage.size === 0) return false; + return ( + coverage.get("WEIGHT") !== false && + coverage.get("BLOOD_PRESSURE_SYS") !== false && + coverage.get("BLOOD_PRESSURE_DIA") !== false + ); +} + /** * Assemble the full snapshot in ONE `Promise.all`. Every sub-read is * timed via the optional `time` wrapper so a regression is attributable @@ -711,12 +742,14 @@ export async function buildDashboardSnapshot( options.time ?? ((_label: string, fn: () => Promise) => fn()); // Probe coverage once up front so the thick `extras` phase only runs - // when the rollup tier is warm. A coverage miss returns `extras: null` - // immediately rather than dropping into the live-SQL fallback that - // would make the whole strip wait on the slowest read (R-firstpaint - // §6 — paint-together vs slowest-wins). + // when the rollup tier is warm for the types it reads. A coverage + // miss on one of those types returns `extras: null` immediately + // rather than dropping into the live-SQL fallback that would make + // the whole strip wait on the slowest read (R-firstpaint §6 — + // paint-together vs slowest-wins). Unrelated uncovered types do NOT + // cold the phase (see `isThickPhaseWarm`). const coverage = await time("coverage", () => probeRollupCoverage(user.id)); - const warm = isFullyCovered(coverage); + const warm = isThickPhaseWarm(coverage); const [slim, mood, extrasResult, flags, medsToday] = await Promise.all([ // A5 — reuse the coverage map already probed above so the slice diff --git a/src/lib/rollups/__tests__/measurement-rollups.test.ts b/src/lib/rollups/__tests__/measurement-rollups.test.ts index 23e86443d..45237f0a3 100644 --- a/src/lib/rollups/__tests__/measurement-rollups.test.ts +++ b/src/lib/rollups/__tests__/measurement-rollups.test.ts @@ -92,6 +92,7 @@ import { enqueueRollupRecompute, ensureUserRollupsFresh, recomputeBucketsForMeasurement, + recomputeUserRollups, } from "../measurement-rollups"; beforeEach(() => { @@ -110,13 +111,23 @@ beforeEach(() => { // resolved promise does not short-circuit the next test's recompute // assertion. _resetEnsureUserRollupsFreshInFlightForTests(); - // Default: $transaction takes an array of pre-built promises (the - // populator passes `[deleteMany(...), createMany(...)]`) and resolves - // them like the real client so the destructured create result is the - // resolved value, not a pending promise. - transaction.mockImplementation(async (operations: unknown[]) => - Promise.all(operations), - ); + // Default: $transaction supports both client shapes the populator + // uses — the batched array form (`[deleteMany(...), createMany(...)]` + // on the ≤CHUNK path) resolves the pre-built promises like the real + // client, and the interactive callback form (large path) invokes the + // callback with a tx proxy wired to the same delegate mocks so the + // assertions can observe the in-transaction calls. + transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === "function") { + return (arg as (tx: unknown) => Promise)({ + measurementRollup: { + deleteMany: mocks.deleteMany, + createMany: mocks.createMany, + }, + }); + } + return Promise.all(arg as unknown[]); + }); // v1.11.1 — persistRollupRows writes via createMany; default the count. createMany.mockResolvedValue({ count: 1 }); }); @@ -366,6 +377,93 @@ describe("recomputeBucketsForMeasurement", () => { }); }); +describe("persistRollupRows — large path (via recomputeUserRollups)", () => { + /** One synthetic aggregate row per call — distinct bucket per index. */ + function syntheticRows(n: number) { + return Array.from({ length: n }, (_, i) => ({ + type: "WEIGHT", + source: "MANUAL", + bucket_start: new Date(Date.UTC(2024, 0, 1 + (i % 1800))), + count: BigInt(1), + mean: 80, + min_value: 80, + max_value: 80, + sum_value: 80, + sd: 0, + slope: 0, + r2: 0, + })); + } + + it("wraps the delete + every insert chunk in ONE interactive transaction with skipDuplicates", async () => { + // 1200 rows → large path (> 500), 3 insert chunks (500/500/200). + queryRawUnsafe.mockResolvedValueOnce(syntheticRows(1200)); + createMany.mockResolvedValue({ count: 400 }); + + const result = await recomputeUserRollups("user-1", { + granularities: ["DAY"], + }); + + // ONE interactive transaction — the callback form, not the batched + // array. The committed-delete-without-inserts window of the + // pre-v1.16.10 shape (delete commits, a concurrent write-hook + // P2002 aborts the chunk loop, the types lose ALL buckets) cannot + // exist when both legs share the transaction. + expect(transaction).toHaveBeenCalledTimes(1); + expect(typeof transaction.mock.calls[0][0]).toBe("function"); + // A generous interactive timeout — the chunk loop on a multi-year + // fold outlives Prisma's 5 s default. + expect(transaction.mock.calls[0][1]).toMatchObject({ + timeout: 120_000, + }); + + // Delete fired once over the full (types × bucket-range) partition. + expect(deleteMany).toHaveBeenCalledTimes(1); + expect(deleteMany.mock.calls[0][0].where.userId).toBe("user-1"); + expect(deleteMany.mock.calls[0][0].where.granularity).toBe("DAY"); + + // 3 chunks, every one tolerant of a mid-transaction hook insert. + expect(createMany).toHaveBeenCalledTimes(3); + expect(createMany.mock.calls[0][0].data).toHaveLength(500); + expect(createMany.mock.calls[1][0].data).toHaveLength(500); + expect(createMany.mock.calls[2][0].data).toHaveLength(200); + for (const call of createMany.mock.calls) { + expect(call[0].skipDuplicates).toBe(true); + } + + expect(result.rowsUpserted).toBe(1200); + }); + + it("a failing chunk rejects through the transaction so the delete rolls back with it", async () => { + queryRawUnsafe.mockResolvedValueOnce(syntheticRows(700)); + createMany + .mockResolvedValueOnce({ count: 500 }) + .mockRejectedValueOnce(new Error("connection reset")); + + // The error must bubble (pg-boss retries the fold); the structural + // guarantee is that the delete ran INSIDE the same transaction, so + // the real client rolls it back — no standalone committed delete. + await expect( + recomputeUserRollups("user-1", { granularities: ["DAY"] }), + ).rejects.toThrow("connection reset"); + expect(transaction).toHaveBeenCalledTimes(1); + expect(typeof transaction.mock.calls[0][0]).toBe("function"); + // The delete was only ever issued through the transaction client — + // it cannot have committed ahead of the failed chunk. + expect(deleteMany).toHaveBeenCalledTimes(1); + }); + + it("the ≤500-row path keeps the batched two-statement transaction", async () => { + queryRawUnsafe.mockResolvedValueOnce(syntheticRows(2)); + createMany.mockResolvedValue({ count: 2 }); + + await recomputeUserRollups("user-1", { granularities: ["DAY"] }); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(Array.isArray(transaction.mock.calls[0][0])).toBe(true); + }); +}); + describe("enqueueRollupRecompute", () => { it("is a silent no-op when no boss is attached", async () => { getGlobalBossMock.mockReturnValue(null); @@ -641,4 +739,31 @@ describe("enqueueBootTimeRollupBackfill", () => { // LEFT JOIN now compares bucket_start on both sides. expect(sqlText).toContain('r."bucket_start" = mt."bucket_start"'); }); + + // v1.16.10 — discovery and fold share one trailing window. Unbounded + // discovery re-flagged accounts whose oldest measurement pre-dated + // the 5-year fold bound on EVERY worker boot: the fold can never + // write buckets for those days, so the per-day gap never closed and + // the full backfill re-ran forever. + it("bounds discovery to the fold window so pre-window history cannot re-flag accounts", async () => { + getGlobalBossMock.mockReturnValue({ send: bossSend }); + queryRaw.mockResolvedValueOnce([]); + const before = Date.now(); + + await enqueueBootTimeRollupBackfill(); + + const after = Date.now(); + const sqlParts = queryRaw.mock.calls[0][0] as TemplateStringsArray; + const sqlText = Array.isArray(sqlParts) ? sqlParts.join("?") : ""; + // The inner scan filters on measured_at >= . + expect(sqlText).toContain('m."measured_at" >='); + // The bound parameter is now − ROLLUP_FOLD_WINDOW_MS (5 years), + // matching the `recomputeUserRollups` default the backfill worker + // runs with. + const boundArg = queryRaw.mock.calls[0][1] as Date; + expect(boundArg).toBeInstanceOf(Date); + const fiveYearsMs = 5 * 365 * 24 * 60 * 60 * 1000; + expect(boundArg.getTime()).toBeGreaterThanOrEqual(before - fiveYearsMs); + expect(boundArg.getTime()).toBeLessThanOrEqual(after - fiveYearsMs); + }); }); diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index b420262f4..b13695620 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -97,6 +97,17 @@ export interface RollupFullBackfillPayload { enqueuedAt: string; } +/** + * Trailing fold window for `recomputeUserRollups` defaults AND the + * boot-time discovery scan. The two MUST share this bound: the fold + * only writes buckets inside it, so a discovery scan over unbounded + * history would re-flag any account whose oldest measurement pre-dates + * the window and re-run the full backfill on every worker boot without + * ever converging. Measurements older than the window deliberately + * never get buckets — the read tier falls back to live SQL for them. + */ +export const ROLLUP_FOLD_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000; + /** Postgres `date_trunc` unit per granularity. */ const DATE_TRUNC_UNIT: Record = { DAY: "day", @@ -141,7 +152,7 @@ export async function recomputeUserRollups( ): Promise<{ rowsUpserted: number; durationMs: number }> { const startedAt = Date.now(); const granularities = opts.granularities ?? ALL_GRANULARITIES; - const fiveYearsAgo = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000); + const fiveYearsAgo = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); const from = opts.from ?? fiveYearsAgo; const to = opts.to ?? new Date(); @@ -526,19 +537,45 @@ async function persistRollupRows( } } - // Large backfill — delete the partition range once, then insert in chunks. - // The brief gap between delete and the first insert can only make a - // concurrent reader fall back to live SQL for those buckets (correct - // value), never serve a wrong one. - await prisma.measurementRollup.deleteMany({ where: deleteWhere }); + // Large backfill — delete the partition range and insert the chunks + // inside ONE interactive transaction. Pre-v1.16.10 the deleteMany + // committed standalone before the chunk loop, and the chunks ran + // without the P2002 tolerance the ≤CHUNK path has: a concurrent + // write-hook insert landing mid-loop made a createMany throw, the + // loop aborted, the delete stayed committed — and every type in the + // window lost ALL its buckets until the next race-free fold (the + // coverage probe then cycled covered/uncovered as the boot backfill + // re-raced the hooks). The transaction keeps the delete invisible + // until every chunk landed; `skipDuplicates` (ON CONFLICT DO + // NOTHING) additionally absorbs a hook row that commits between two + // chunks — READ COMMITTED lets a later statement see it — without + // aborting the batch. The hook's row is the per-(type, day) loser of + // the same aggregate, so skipping the fold's duplicate is benign. + // + // Transaction sizing: one call covers ONE granularity, so the worst + // case is the full 5-year DAY fold of a multi-source power user — + // tens of thousands of rows in 500-row chunks, well inside Postgres + // limits but potentially slow on self-host disks. A per-type + // transaction was considered and rejected: it would re-open the + // committed-delete gap between types sharing the bucket range. The + // 120 s timeout (Prisma default: 5 s) covers the chunk round-trips + // with headroom; the fold runs on the serial backfill worker, so a + // long-held transaction cannot pile up behind itself. let touched = 0; - for (let i = 0; i < rows.length; i += CHUNK) { - const slice = rows.slice(i, i + CHUNK); - const res = await prisma.measurementRollup.createMany({ - data: slice.map(toData), - }); - touched += res.count; - } + await prisma.$transaction( + async (tx) => { + await tx.measurementRollup.deleteMany({ where: deleteWhere }); + for (let i = 0; i < rows.length; i += CHUNK) { + const slice = rows.slice(i, i + CHUNK); + const res = await tx.measurementRollup.createMany({ + data: slice.map(toData), + skipDuplicates: true, + }); + touched += res.count; + } + }, + { maxWait: 10_000, timeout: 120_000 }, + ); return touched; } @@ -779,6 +816,15 @@ export async function enqueueBootTimeRollupBackfill(): Promise<{ // has been retired. The maintainer's tenant converged at v1.4.40, so the // arm was a permanent no-op seq scan over `measurement_rollups`. // Per-day missing coverage remains the sole discovery anchor. + // + // v1.16.10 — discovery is bounded to the same trailing window the + // fold writes (`ROLLUP_FOLD_WINDOW_MS`). Unbounded, any account + // with measurements older than the window was re-flagged on every + // boot: the fold can never produce buckets for those days, so the + // per-day gap re-surfaced forever and the full backfill re-ran on + // every restart. Days outside the window are not foldable by + // design and must not anchor discovery. + const foldWindowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); const users = await prisma.$queryRaw>` SELECT DISTINCT mt."user_id" AS id FROM ( @@ -788,6 +834,7 @@ export async function enqueueBootTimeRollupBackfill(): Promise<{ date_trunc('day', m."measured_at") AS bucket_start FROM measurements m WHERE m."deleted_at" IS NULL + AND m."measured_at" >= ${foldWindowStart} ) mt LEFT JOIN measurement_rollups r ON r."user_id" = mt."user_id"