mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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: ({
|
||||
|
||||
Reference in new issue
Block a user