From 3d1a1cec869bfe6abc1115f14abb9ba72399360d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 05:05:28 +0200 Subject: [PATCH] fix(rollups): align the recompute window to whole buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recomputeUserRollups filtered measurements by a caller-supplied from/to but grouped them by date_trunc, and persistRollupRows then deletes every rollup row in the bucket range it is about to write. A from that landed mid-bucket therefore replaced a complete bucket with an aggregate computed only from the readings after that instant. Every caller passes a moving instant — now minus ninety days on the read-path refresh, an arbitrary instant on the coverage fallback, now minus five years on the boot backfill and the restore path — so a different oldest bucket was corrupted on each invocation and nothing repaired it. Snap both edges out to whole buckets via bucketSpan, whose own contract note already required exactly this of every recompute. --- src/lib/rollups/measurement-rollups.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index b4f4727ca..519750ed2 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -190,12 +190,32 @@ export async function recomputeUserRollups( let rowsUpserted = 0; for (const granularity of granularities) { + // Snap the window out to whole buckets before aggregating. The + // aggregate filters rows by `measured_at` but GROUPS by + // `date_trunc(, measured_at)`, and `persistRollupRows` + // then deletes every rollup row in the [min, max] bucket range it is + // about to write. A `from` that lands mid-bucket therefore replaced a + // COMPLETE bucket with an aggregate computed only from the readings + // after that instant — and because the callers pass a moving instant + // (`Date.now() - 90d` on the read-path refresh, an arbitrary + // client-supplied instant on the coverage fallback, `now - 5y` on the + // boot backfill and the restore path), a different oldest day was + // corrupted on every invocation and nothing ever repaired it. Same + // hazard on the `to` edge for any row measured later in the closing + // bucket. `bucketSpan` is the canonical truncator — its own contract + // note already required every recompute to cover whole spans; this is + // the caller that did not. + const alignedFrom = bucketSpan(from, granularity).from; + const alignedTo = + to > alignedFrom + ? bucketSpan(new Date(to.getTime() - 1), granularity).to + : bucketSpan(from, granularity).to; const rows = await runRollupAggregate({ userId, types: opts.types, granularity, - from, - to, + from: alignedFrom, + to: alignedTo, }); if (rows.length === 0) continue;