From 3d5d5fc4ff9cacac04d8fde5a4d9849b8d6850ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 31 Oct 2026 11:25:04 +0100 Subject: [PATCH] fix(rollups): count a type as covered only when its buckets reach its oldest reading The coverage probe called a type covered as soon as one DAY bucket existed. A write hook folds the day it writes, so a type whose history was not yet folded (an account before its backfill, or a read racing the detached refresh) had one fresh bucket, took the bucket-composed path, and its summary counted that one day: three weight readings read as one. A type is now covered when its oldest DAY bucket starts no later than its oldest live reading inside the fold window. The bucket holding a reading starts at or before it in any time zone, so the comparison holds without knowing the account's day boundaries. Both sides are one index descent per type; anything not covered falls back to the live aggregate as before. --- src/lib/rollups/measurement-coverage.ts | 45 +++++++++----- .../integration/rollup-coverage-probe.test.ts | 58 +++++++++++++++++-- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/lib/rollups/measurement-coverage.ts b/src/lib/rollups/measurement-coverage.ts index 9a28ff997..d9703e9b0 100644 --- a/src/lib/rollups/measurement-coverage.ts +++ b/src/lib/rollups/measurement-coverage.ts @@ -24,11 +24,14 @@ import { joinCoverageProbe } from "@/lib/rollups/coverage-inflight"; import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups"; /** - * Per-type coverage map. `true` means at least one DAY rollup row - * exists for this `(user, type)` pair; the caller can safely compose - * `count / min / max / mean` from the bucket table for this type. - * `false` means the bucket table is empty for this type and the caller - * must fall back to the live aggregate. + * Per-type coverage map. `true` means the DAY buckets for this + * `(user, type)` pair reach back to its oldest live reading inside the + * fold window, so the caller can compose `count / min / max / mean` from + * the bucket table for this type. `false` means they do not (no bucket at + * all, or only the newest days: a write hook folded one day before the + * history was folded) and the caller must fall back to the live aggregate. + * A bucket set that merely exists is not coverage: with one fresh bucket + * and years of unfolded readings, the composed count was that one day. * * Types the user has never logged are absent from the map — callers * never need to ask about a type with zero measurements. @@ -42,9 +45,12 @@ export type RollupCoverageMap = Map; * The distinct-type set is read as a loose index scan: a recursive CTE * that hops from one type to the next along the partial * `(user_id, type, measured_at)` live index, one index descent per type - * instead of a walk over every live row the user has. Coverage is an - * `EXISTS` per type on the rollup index, which stops at the first DAY - * bucket rather than counting them all. + * instead of a walk over every live row the user has. Coverage compares, + * per type, the oldest DAY bucket (one descent of the rollup index) with + * the oldest live reading inside the fold window (one descent of the live + * index): the bucket holding a reading starts at or before it, whatever + * the account's time zone, so the oldest bucket starting no later than + * the oldest reading means the fold reached the start of the history. * * The earlier shape, `SELECT DISTINCT type` joined to a `COUNT(r.*)` * over every DAY bucket, read all of a user's live rows and all of their @@ -62,6 +68,7 @@ export function probeRollupCoverage( } async function readRollupCoverage(userId: string): Promise { + const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); const rows = await prisma.$queryRaw< Array<{ type: string; has_buckets: boolean }> >` @@ -89,12 +96,22 @@ async function readRollupCoverage(userId: string): Promise { ) SELECT t."type"::text AS type, - EXISTS ( - SELECT 1 - FROM measurement_rollups r - WHERE r.user_id = ${userId} - AND r."type" = t."type" - AND r.granularity = 'DAY' + COALESCE( + ( + SELECT MIN(r.bucket_start) + FROM measurement_rollups r + WHERE r.user_id = ${userId} + AND r."type" = t."type" + AND r.granularity = 'DAY' + ) <= ( + SELECT MIN(m.measured_at) + FROM measurements m + WHERE m.user_id = ${userId} + AND m."type" = t."type" + AND m."deleted_at" IS NULL + AND m.measured_at >= ${windowStart} + ), + false ) AS has_buckets FROM live_types t WHERE t."type" IS NOT NULL diff --git a/tests/integration/rollup-coverage-probe.test.ts b/tests/integration/rollup-coverage-probe.test.ts index 2d4b118ee..05f99cf44 100644 --- a/tests/integration/rollup-coverage-probe.test.ts +++ b/tests/integration/rollup-coverage-probe.test.ts @@ -1,10 +1,12 @@ /** * The rollup coverage probe reads the distinct live types as a loose index - * scan and coverage as a per-type `EXISTS`. The previous shape (a DISTINCT - * over every live row joined to a COUNT over every DAY bucket) is kept here - * as the oracle: both must return the same map on the same fixture, including - * the edges that decide the read-swap — tombstoned types, types with only - * coarser buckets, another account's buckets, and an account with nothing. + * scan. The previous shape (a DISTINCT over every live row joined to a COUNT + * over every DAY bucket) is kept here as the oracle for which types appear + * and for the edges that decide the read-swap: tombstoned types, types with + * only coarser buckets, another account's buckets, and an account with + * nothing. Coverage itself is stricter than the oracle's "any DAY bucket": + * the buckets must reach back to the type's oldest live reading, which the + * second case pins. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -125,4 +127,50 @@ describe("probeRollupCoverage", () => { expect(empty.size).toBe(0); expect(empty).toEqual(await oracle(EMPTY)); }); + + it("does not count a type as covered by buckets for its newest days only", async () => { + const prisma = getPrismaClient(); + const day = 86_400_000; + const base = Date.UTC(2026, 0, 10); + // Three weight readings, but only the newest day folded: what a write + // hook leaves when it runs before the history was folded. + await prisma.measurement.createMany({ + data: [0, 1, 2].map((i) => ({ + userId: USER, + type: "WEIGHT" as const, + value: 80 + i, + unit: "kg", + measuredAt: new Date(base + i * day + 9 * 3_600_000), + })), + }); + const bucket = (i: number) => ({ + userId: USER, + type: "WEIGHT" as const, + granularity: "DAY" as const, + bucketStart: new Date(base + i * day), + source: "MANUAL" as const, + count: 1, + mean: 80 + i, + minValue: 80 + i, + maxValue: 80 + i, + }); + await prisma.measurementRollup.create({ data: bucket(2) }); + expect((await probeRollupCoverage(USER)).get("WEIGHT")).toBe(false); + + // Once the fold reaches the oldest reading's day, the type is covered. + await prisma.measurementRollup.createMany({ data: [bucket(0), bucket(1)] }); + expect((await probeRollupCoverage(USER)).get("WEIGHT")).toBe(true); + + // A reading older than the oldest bucket uncovers it again. + await prisma.measurement.create({ + data: { + userId: USER, + type: "WEIGHT", + value: 79, + unit: "kg", + measuredAt: new Date(base - day), + }, + }); + expect((await probeRollupCoverage(USER)).get("WEIGHT")).toBe(false); + }); });