mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
perf(insights): skip heavy live aggregate when rollups are populated
v1.4.35 left the regression intact: comprehensive-aggregator and the slim summaries slice still ran the full COUNT/MIN/MAX/AVG `$queryRaw` in parallel with the rollup read, only preferring the bucket-derived values when the count happened to match. Net round-trip count saved was zero on Marc's 311k-row account. Promote the rollup table to the canonical source on the happy path: gate on a cheap `SELECT COUNT(*) FROM measurement_rollups` and, when non-zero, drop the heavy aggregate. The bucket composition feeds count/min/max/mean, a narrow `$queryRaw` keeps the non-composable windowed columns (stddev/anomalies/avg7/avg30/avg30_last_month + the three slope tuples), and the legacy heavy aggregate stays as the cold-mount fallback for accounts whose backfill is mid-flight. Unit tests pin the contract: the heavy aggregate is NOT called on the rollup-fresh branch. Integration test verifies byte-identical count / min / max / mean against a parallel live SQL pass on a populated fixture.
This commit is contained in:
1 parent
3f55b6f782
commit
13f8905ea1
5 files changed
+1196
-496
No files matched your search
@@ -630,4 +630,108 @@ describe("measurement rollups — integration", () => {
|
||||
|
||||
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
|
||||
});
|
||||
|
||||
// ─── v1.4.36 rollup-fresh skip-live contract ──────────────────
|
||||
//
|
||||
// The v1.4.35 read-swap kept the heavy live aggregate running in
|
||||
// parallel with the rollup read. v1.4.36 drops that — when the
|
||||
// rollup table has DAY buckets for the user, the heavy aggregate
|
||||
// is bypassed entirely. This test pins that the rollup-derived
|
||||
// response is still byte-identical to a parallel live aggregate
|
||||
// over the same rows AND that the response shape matches the
|
||||
// pre-swap contract for every field.
|
||||
|
||||
it("rollup-fresh response matches live SQL for every DataSummary field", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "rollup-skip-live-user",
|
||||
email: "rollup-skip-live@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
|
||||
// 60 readings over 30 days, two types, deliberately spread so
|
||||
// anomalies + slopes + windowed avgs all have signal.
|
||||
const now = Date.now();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const seedRows: Array<{
|
||||
userId: string;
|
||||
type: "WEIGHT" | "PULSE";
|
||||
value: number;
|
||||
unit: string;
|
||||
source: "MANUAL";
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
for (let day = 0; day < 30; day++) {
|
||||
seedRows.push({
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 80 + day * 0.05 + ((day * 7) % 5) * 0.1,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(now - (29 - day) * dayMs - day * 60 * 1000),
|
||||
});
|
||||
seedRows.push({
|
||||
userId: user.id,
|
||||
type: "PULSE",
|
||||
value: 65 + ((day * 3) % 11) * 0.4,
|
||||
unit: "bpm",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(now - (29 - day) * dayMs - day * 90 * 1000),
|
||||
});
|
||||
}
|
||||
await prisma.measurement.createMany({ data: seedRows });
|
||||
|
||||
// Pre-fold the rollup buckets so the rollup-fresh path engages.
|
||||
await recomputeUserRollups(user.id, { granularities: ["DAY"] });
|
||||
|
||||
// Parallel live aggregation over the same 90-day window using the
|
||||
// exact heavy-aggregate shape the cold-mount fallback runs.
|
||||
const ninetyDaysAgo = new Date(now - 90 * dayMs);
|
||||
const live = await prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
mean_value: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type"
|
||||
`;
|
||||
|
||||
const aggregate = await buildComprehensiveAggregate(user.id);
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
expect(live.length).toBeGreaterThan(0);
|
||||
for (const liveRow of live) {
|
||||
const summary = aggregate.summaries[liveRow.type];
|
||||
expect(summary, `summary for ${liveRow.type}`).toBeDefined();
|
||||
// count / min / max / mean compose from buckets — byte-identical
|
||||
// to live SQL over the same rows.
|
||||
expect(summary.count).toBe(Number(liveRow.count));
|
||||
expect(summary.min).toBe(round2(liveRow.min_value));
|
||||
expect(summary.max).toBe(round2(liveRow.max_value));
|
||||
expect(summary.mean).toBe(round2(liveRow.mean_value));
|
||||
// Narrow aggregate fills the non-composable windowed columns.
|
||||
// Slope tuples and avg7/avg30 are sourced from live SQL even on
|
||||
// the rollup-fresh path so the values are present (not null) as
|
||||
// long as the windowed avg has rows to chew on.
|
||||
expect(summary.avg30).not.toBeNull();
|
||||
// Latest reading comes from the DISTINCT ON pass — never null
|
||||
// when the user has rows for the type.
|
||||
expect(summary.latest).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user