v1.4.35 — persistent measurement rollups + partial read-swap

Foundation release for the persistent measurement_rollups cache tier.
Adds the additive table + enum, populates it from every measurement
write hook, lands the Apple-Health-import end-of-job backfill, and
ships the parity-checked read-swap on two reader surfaces.

The comprehensive insights aggregator and the slim analytics
summaries slice now source count / min / max / mean per type from the
DAY rollup buckets via aggregateBuckets, plus the comprehensive
dailyByType correlation feed straight from the bucket means. Slope /
R² / standard deviation / anomaly counts stay on live SQL — they do
not compose across DAY buckets. A defensive parity check against the
live aggregate's COUNT(*) falls back to live SQL when divergence is
detected.

Includes the new scripts/backfill-rollups.ts CLI for the one-shot
fold of historical measurements into all four granularities.

Tests: 4280 unit + 228 integration. tsc + lint clean. Migration
0067_v1434_measurement_rollups is idempotent under re-run.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 09:55:33 +02:00
1 parent 9715831f82
commit 8dc9edb65e
26 files changed
+2650 -65

No files matched your search

@@ -9,6 +9,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
// v1.4.35 — slim slice now reads DAY buckets from
// `measurement_rollups` for the parity-checked composition. The
// freshness watermark inside `ensureUserRollupsFresh` also pokes
// `measurementRollup.findFirst`; mock both so the helper runs
// without a container.
measurementRollup: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
},
}));
@@ -20,9 +29,20 @@ import { prisma } from "@/lib/db";
import { computeSummariesSlice } from "../summaries-slice";
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
const ROLLUP_FIND_MANY =
prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
const ROLLUP_FIND_FIRST =
prisma.measurementRollup.findFirst as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
RAW.mockReset();
ROLLUP_FIND_MANY.mockReset();
ROLLUP_FIND_FIRST.mockReset();
// Defaults: empty rollup buckets so the parity check falls back to
// live SQL (the pre-backfill behaviour). Individual tests opt in
// to rollup-derived values by overriding the mock.
ROLLUP_FIND_MANY.mockResolvedValue([]);
ROLLUP_FIND_FIRST.mockResolvedValue(null);
});
afterEach(() => {
@@ -32,6 +52,7 @@ afterEach(() => {
describe("computeSummariesSlice", () => {
it("returns the empty-summary skeleton when the user has no rows", async () => {
// Pass 1 (aggregates) returns empty, pass 2 (latest) returns empty.
// Pass 3 (rollup.findMany) is defaulted to [] in beforeEach.
RAW.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const result = await computeSummariesSlice("user-1");
+79 -9
View File
@@ -10,13 +10,15 @@
* it ever paints — yet the user waits on the full chunked walk.
*
* This helper resolves the same `DataSummary` shape (per-type) with
* two SQL passes:
* three passes (two SQL + one rollup-table read):
*
* 1. **`groupBy` + windowed aggregates** — one `$queryRaw` carrying
* `COUNT`/`MIN`/`MAX`/`AVG`, the 7-day and 30-day `AVG` slices
* via `FILTER`, plus `regr_slope` + `regr_r2` for the slope
* tuples at 7d / 30d / 90d windows. Postgres folds all of this
* into one index scan over `measurements WHERE user_id = …`.
* The `COUNT / MIN / MAX / AVG` columns stay on this pass as
* the live-SQL fallback for the v1.4.35 rollup parity check.
*
* 2. **`DISTINCT ON (type)` latest read** — one `$queryRaw`
* returning the most-recent `(type, value)` pair per type so the
@@ -24,6 +26,12 @@
* we need the value of the row at `MAX(measured_at)`, not the
* largest value ever recorded.
*
* 3. **`measurement_rollups` DAY-bucket read** (v1.4.35) — folded
* into `count / min / max / mean` via `aggregateBuckets`. Used
* when the composed count agrees byte-for-byte with the live
* `COUNT(*)`; falls back to live for accounts that never ran
* the explicit backfill or that are mid-flight on the populator.
*
* Fields the slim shape intentionally omits (the dashboard never
* reads them on first paint):
* - `anomalyCount`: would require an extra per-type read for the
@@ -43,6 +51,8 @@ import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
import { aggregateBuckets } from "@/lib/measurements/rollup-read";
interface AggregateRow {
type: string;
@@ -136,10 +146,19 @@ export interface SummariesSlice {
export async function computeSummariesSlice(
userId: string,
): Promise<SummariesSlice> {
// The two passes run in parallel — they target the same index path
// (`measurements (user_id, type, measured_at)`) but Postgres can
// serve them off the buffer cache concurrently.
const [aggregates, latests] = await Promise.all([
// v1.4.35 — keep the persistent rollup table current before the
// bucket read fires. On a warm process this is a single watermark
// query; on a cold mount it folds the trailing 90-day window into
// the DAY rollup so the bucket read below has something to compose.
await ensureUserRollupsFresh(userId);
// Three passes in parallel. The first two target the same
// `measurements (user_id, type, measured_at)` index path; the third
// hits the much smaller `measurement_rollups` table (one row per
// bucket, not per row). On users with a complete backfill the
// bucket scan dominates only marginally; on un-backfilled users it
// returns a short list and the parity check falls back to live.
const [aggregates, latests, dayBuckets] = await Promise.all([
prisma.$queryRaw<AggregateRow[]>`
SELECT
m."type"::text AS type,
@@ -202,6 +221,24 @@ export async function computeSummariesSlice(
WHERE m."user_id" = ${userId}
ORDER BY m."type", m."measured_at" DESC
`,
// v1.4.35 — every DAY rollup bucket for the user. The slim slice's
// `count / min / max / mean` is all-time, so we read the bucket
// table without a `bucketStart` window. The defensive parity
// check below uses the live aggregate's `COUNT(*)` to decide
// whether the rollup is complete for this account (post-backfill
// → composed) or partial (pre-backfill → falls back to live).
prisma.measurementRollup.findMany({
where: { userId, granularity: "DAY" },
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
select: {
type: true,
bucketStart: true,
count: true,
mean: true,
minValue: true,
maxValue: true,
},
}),
]);
const latestByType = new Map<string, number>();
@@ -240,17 +277,50 @@ export async function computeSummariesSlice(
};
}
// v1.4.35 — partition the DAY buckets by type so the per-type loop
// can compose `count / min / max / mean` from them in O(1).
const bucketsByType = new Map<
string,
Array<{
day: Date;
count: number;
mean: number;
minValue: number;
maxValue: number;
}>
>();
for (const b of dayBuckets) {
const list = bucketsByType.get(b.type) ?? [];
list.push({
day: b.bucketStart,
count: b.count,
mean: b.mean,
minValue: b.minValue,
maxValue: b.maxValue,
});
bucketsByType.set(b.type, list);
}
let totalRows = 0;
for (const row of aggregates) {
const count = Number(row.count);
const liveCount = Number(row.count);
// Compose count/min/max/mean from the DAY buckets when the
// composed count agrees with the live `COUNT(*)`. The slim slice's
// window is all-time, so a divergent count signals either an
// un-backfilled account (rollup only covers the trailing 90d
// `ensureUserRollupsFresh` warmed up) or a mid-flight populator —
// both cases want the live SQL values, which we already have.
const composed = aggregateBuckets(bucketsByType.get(row.type) ?? []);
const useRollup = composed.count === liveCount;
const count = useRollup ? composed.count : liveCount;
totalRows += count;
const latest = latestByType.get(row.type) ?? null;
summaries[row.type] = {
count,
latest,
min: round2(row.min_value),
max: round2(row.max_value),
mean: round2(row.mean_value),
min: useRollup ? round2(composed.min) : round2(row.min_value),
max: useRollup ? round2(composed.max) : round2(row.max_value),
mean: useRollup ? round2(composed.mean) : round2(row.mean_value),
avg7: round2(row.avg7),
avg30: round2(row.avg30),
slope7: buildSlope(row.slope7, row.r2_7),