fix(measurements): recompute rollups after cumulative consolidation drains

The two cumulative drains soft-deleted per-sample rows and wrote a daily
total but never recomputed the affected DAY rollup bucket. The rollup tier
reads only `deleted_at IS NULL`, so the stored `sumValue` kept the
pre-drain double-counted total indefinitely — dashboard and every rolled-up
week/month/year step or energy figure stayed wrong and never self-healed,
because the backfill only re-flags days with no DAY row and the
daily-series read-swap only re-folds on a day-count change.

Call `recomputeBucketsForMeasurement` after each per-day write, mirroring
the daily-mean drain that already does this. Added per-drain (not hoisted)
so the mean drain, which recomputes inside its own writer, is not
double-recomputed.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 03:27:55 +02:00
1 parent c371310fc0
commit d85ac3e555
3 files changed
+50

No files matched your search

@@ -1,5 +1,14 @@
import { describe, expect, it, vi } from "vitest";
// The SUM drain now recomputes the affected DAY rollup bucket after each
// per-day fold (so a drained day's stale double-counted sum self-heals).
// Mock it: these are pure-logic tests over a Prisma mock with no DB, and
// the real recompute issues raw SQL against the singleton client.
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
}));
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import {
bucketRowsByUserDay,
canonicalDailyTimestamp,
@@ -444,6 +453,26 @@ describe("drainPerSampleCumulative — late-sync fold into existing total", () =
const createArg = create.mock.calls[0]?.[0] as { data: { value: number } };
expect(createArg.data.value).toBe(500);
});
it("recomputes the DAY rollup bucket for the drained day so the stale sum self-heals", async () => {
vi.mocked(recomputeBucketsForMeasurement).mockClear();
const { prisma } = buildFoldMock(null);
await drainPerSampleCumulative(prisma, { log: () => {} });
// Without this the rollup tier keeps serving the pre-drain
// double-counted sumValue forever (it reads only deleted_at IS NULL).
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(1);
const [userId, type, canonicalTs] = vi.mocked(
recomputeBucketsForMeasurement,
).mock.calls[0]!;
expect(userId).toBe("user-1");
expect(type).toBe("ACTIVITY_STEPS");
// Canonical local-noon of the Berlin day 2026-05-16 (CEST, UTC+2).
expect((canonicalTs as Date).toISOString()).toBe(
"2026-05-16T10:00:00.000Z",
);
});
});
// Root cause of the v1.18.10 bloat: a single day whose mint collided with the
@@ -39,6 +39,7 @@
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { dailyStatsExternalId } from "./apple-health-mapping";
import {
@@ -259,6 +260,16 @@ export async function consolidateLegacySteps(
});
removed = del.count;
});
// Re-aggregate the affected (user, type, day) rollup bucket after the
// soft-delete commits. The legacy granular rows that just dropped out
// of the live read were being summed alongside any existing daily-stats
// total, so the DAY rollup bucket carried a double-counted total. The
// rollup tier reads only `deleted_at IS NULL`; without this recompute
// the stale bucket persists forever. Mirrors the mean drain's
// post-write recompute exactly.
await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp);
return { kind: "written", sourceRowsRemoved: removed };
} catch (err) {
// A pre-existing row can collide on the second unique index
@@ -34,6 +34,7 @@
import { Prisma } from "@/generated/prisma/client";
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import {
CUMULATIVE_HK_TYPES,
@@ -355,6 +356,15 @@ export async function drainPerSampleCumulative(
if (!isUniqueConstraintViolation(err)) throw err;
removed = await foldOnce();
}
// Re-aggregate the affected (user, type, day) rollup bucket after the
// fold commits — the per-sample rows are now gone and the canonical
// daily-sum row is the only live reading. The rollup tier reads only
// `deleted_at IS NULL`, so without this the day's DAY bucket keeps the
// stale pre-drain (double-counted) sumValue forever and never
// self-heals. Mirrors the mean drain's post-write recompute exactly.
await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp);
return { kind: "written", sourceRowsRemoved: removed };
},
recordBucket: ({