fix(dashboard,rollups): the score survives new data types and folds stop eating buckets

The snapshot's thick phase gated on every type the account had ever
logged having rollup coverage — one fresh reading from a new
integration nulled the health score and the blood-pressure extras
while the insights page showed a score for the same account. The gate
now checks only the three types the phase reads, per the established
per-type pattern. Underneath sat the destabiliser: the large rollup
fold deleted the window's buckets and re-inserted without a
transaction or duplicate tolerance, so a racing sync write aborted the
insert and left the delete committed — whole types lost their buckets
until the next clean fold, and accounts with data older than the
five-year fold window re-ran the full backfill on every boot because
discovery scanned unbounded history. Delete and insert now commit
atomically with duplicate tolerance, discovery shares the fold's
bound, the provisional ring says the score is being computed when the
inputs exist and reserves the no-data wording for accounts that have
none, and the greeting leads the band typographically.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 17:42:44 +02:00
1 parent 7d3557a3fd
commit d142a6ed31
6 files changed
+448 -37

No files matched your search

@@ -494,6 +494,77 @@ describe("<DashboardHero> — 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("<DashboardHero> — typographic hierarchy", () => {
it("the greeting leads: larger + heavier than the verdict line", () => {
const html = render(baseSnapshot());
const greeting = html.match(
/<p[^>]*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(
/<p[^>]*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("<DashboardHero> — source pins", () => {
@@ -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 `<ScoreRing>` 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({
>
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
{/* 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. */}
<p
data-slot="dashboard-hero-greeting"
className="text-muted-foreground text-sm"
className="text-foreground text-lg font-semibold tracking-tight sm:text-xl"
>
{welcomeText}
</p>
@@ -216,7 +245,7 @@ export function DashboardHero({
<p
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground text-base font-medium"
className="text-foreground/90 text-base font-medium"
>
{sentence}
</p>
@@ -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")
}
/>
</div>
@@ -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]]));
+44 -11
View File
@@ -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 ?? (<T>(_label: string, fn: () => Promise<T>) => 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
@@ -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<unknown>)({
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 >= <fold window start>.
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);
});
});
+60 -13
View File
@@ -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<RollupGranularity, string> = {
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<Array<{ id: string }>>`
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"