Files
HealthLog/tests/integration/dashboard-summary-reads.test.ts
T
Marc-André Bombeck b6e9eb98f7 test(integration): size the all-time count case to its measured cost
The rollup parity case writes over half a million readings and folds them
into DAY buckets: about 35 s on a CI runner alone. Run beside two other heavy
files on the same Postgres it took 1.7 times as long and crossed the 60 s
default. Nothing in the case waits on a lock: its advisory locks are scoped
to the worker's own database, and the measurement with the database capped
at two CPUs (14.6 s alone, 25.3 s beside the sharing matrix and the restore
memory test) shows plain contention. The case gets a 150 s budget, twice the
contended estimate. HEALTHLOG_INTEGRATION_PG_CPUS caps the database
container so a CI runner's contention can be reproduced locally.
2026-10-22 07:18:57 +02:00

347 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.38.5 — the dashboard's two per-type reads, against a real Postgres.
*
* Both were rewritten for their query plans, not for their results: the
* latest-reading read moved from `DISTINCT ON (type)` to a LATERAL join
* over the type list, and the sparkline read gained the `type` predicate
* its rollup key needs. Neither change may move a single row.
*
* So the reference implementation is the OLD SQL, kept verbatim in this
* file and run side by side with the shipped readers over the same
* fixture. The rows have to match exactly — value, ordering, and the
* types that are present at all.
*
* The fixture deliberately carries the three cases that could break the
* rewrite: a type whose only rows are tombstoned (must drop out of BOTH
* results, as an inner join and a filtered group both do), a type with
* no rows at all (same), and a rollup table holding coarser
* granularities the DAY read must not pick up.
*
* Plan shape is asserted by the unit companion
* (`src/__tests__/dashboard-summary-read-plan-guard.test.ts`) rather than
* here: a testcontainer fixture is far too small for the planner to
* choose the same access paths a real account produces, so an EXPLAIN
* assertion at this size would pin nothing.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
allTimeRollupBoundary,
readAllTimeCounts,
readLatestEver,
readSparkBuckets,
type LatestEverRow,
type SparklineRow,
} from "@/lib/dashboard/summary-reads";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import type {
MeasurementType,
RollupGranularity,
} from "@/generated/prisma/client";
const SPARK_DAYS = 7;
const TYPES = [...measurementTypeEnum.options] as MeasurementType[];
/** The pre-v1.38.5 latest-reading read, kept as the parity reference. */
function legacyLatestEver(userId: string): Promise<LatestEverRow[]> {
return getPrismaClient().$queryRaw<LatestEverRow[]>`
SELECT DISTINCT ON (m."type")
m."type" AS type,
m."value"::double precision AS value,
m."measured_at" AS measured_at
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`;
}
/** The pre-v1.38.5 sparkline read, without the `type` predicate. */
function legacySparkBuckets(userId: string): Promise<SparklineRow[]> {
return getPrismaClient().$queryRaw<SparklineRow[]>`
SELECT type, bucket_start, mean, count, sum_value
FROM (
SELECT
r."type" AS type,
r."bucket_start" AS bucket_start,
r."mean"::double precision AS mean,
r."count"::int AS count,
r."sum_value"::double precision AS sum_value,
ROW_NUMBER() OVER (
PARTITION BY r."type"
ORDER BY r."bucket_start" DESC
) AS rn
FROM measurement_rollups r
WHERE r."user_id" = ${userId}
AND r."granularity" = 'DAY'
) sub
WHERE rn <= ${SPARK_DAYS}
ORDER BY type, bucket_start ASC
`;
}
const DAY = 86_400_000;
async function seedAccount(): Promise<string> {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "summary-reads-user",
email: "summary-reads-user@example.test",
role: "USER",
},
});
// Live readings across several types, several rows each, so the
// "latest wins" pick has something to choose between.
const live: MeasurementType[] = [
"WEIGHT",
"PULSE",
"BLOOD_PRESSURE_SYS",
"ACTIVITY_STEPS",
"SLEEP_DURATION",
];
const base = Date.UTC(2026, 0, 1);
const rows = live.flatMap((type, ti) =>
Array.from({ length: 12 }, (_, i) => ({
userId: user.id,
type,
value: 50 + ti * 10 + i,
unit: "x",
source: "MANUAL" as const,
measuredAt: new Date(base + i * DAY + ti * 3_600_000),
deletedAt: null as Date | null,
})),
);
// BODY_FAT exists only as tombstones: both reads must drop it.
rows.push(
...Array.from({ length: 4 }, (_, i) => ({
userId: user.id,
type: "BODY_FAT" as MeasurementType,
value: 20 + i,
unit: "%",
source: "MANUAL" as const,
measuredAt: new Date(base + i * DAY + 7_200_000),
deletedAt: new Date(base + 30 * DAY),
})),
);
// A live type whose newest row is tombstoned — the read must fall back
// to the newest surviving row, not report the deleted one.
rows.push(
{
userId: user.id,
type: "BLOOD_GLUCOSE" as MeasurementType,
value: 95,
unit: "mg/dL",
source: "MANUAL" as const,
measuredAt: new Date(base + 5 * DAY),
deletedAt: null,
},
{
userId: user.id,
type: "BLOOD_GLUCOSE" as MeasurementType,
value: 999,
unit: "mg/dL",
source: "MANUAL" as const,
measuredAt: new Date(base + 9 * DAY),
deletedAt: new Date(base + 30 * DAY),
},
);
await prisma.measurement.createMany({ data: rows });
// DAY buckets past the SPARK_DAYS window, plus coarser granularities
// the DAY read must ignore.
const bucketRows = live.flatMap((type, ti) =>
Array.from({ length: 11 }, (_, i) => ({
userId: user.id,
type,
granularity: "DAY" as RollupGranularity,
bucketStart: new Date(base + i * DAY),
source: "MANUAL" as const,
count: 3,
mean: 60 + ti + i,
minValue: 50,
maxValue: 70,
sumValue: 180 + ti,
})),
);
bucketRows.push(
...(["WEEK", "MONTH", "YEAR"] as const).flatMap((granularity) =>
live.map((type, ti) => ({
userId: user.id,
type,
granularity,
bucketStart: new Date(base + ti * DAY),
source: "MANUAL" as const,
count: 9,
mean: 1000 + ti,
minValue: 900,
maxValue: 1100,
sumValue: 9000 + ti,
})),
),
);
await prisma.measurementRollup.createMany({ data: bucketRows });
return user.id;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("dashboard summary reads — parity with the pre-v1.38.5 SQL", () => {
it("returns the identical latest-reading rows the DISTINCT ON returned", async () => {
const userId = await seedAccount();
const [legacy, shipped] = await Promise.all([
legacyLatestEver(userId),
readLatestEver(getPrismaClient(), userId, TYPES),
]);
expect(shipped).toEqual(legacy);
// Not a vacuous pass: the fixture has to have produced rows.
expect(shipped.length).toBe(6);
// Tombstone-only type is absent from both; a type with no rows too.
expect(shipped.map((r) => r.type)).not.toContain("BODY_FAT");
expect(shipped.map((r) => r.type)).not.toContain("VO2_MAX");
// The newest LIVE glucose row wins, not the newer tombstoned one.
expect(shipped.find((r) => r.type === "BLOOD_GLUCOSE")?.value).toBe(95);
});
it("returns the identical sparkline buckets the type-less read returned", async () => {
const userId = await seedAccount();
const [legacy, shipped] = await Promise.all([
legacySparkBuckets(userId),
readSparkBuckets(getPrismaClient(), userId, TYPES, SPARK_DAYS),
]);
expect(shipped).toEqual(legacy);
// Five types × the trailing SPARK_DAYS buckets, and nothing coarser.
expect(shipped.length).toBe(5 * SPARK_DAYS);
expect(shipped.every((r) => r.mean < 1000)).toBe(true);
});
it("orders both reads the way the callers' per-type maps expect", async () => {
const userId = await seedAccount();
const latest = await readLatestEver(getPrismaClient(), userId, TYPES);
// Enum declaration order, exactly as `ORDER BY m."type"` produced it.
const enumRank = new Map(TYPES.map((t, i) => [t, i]));
const ranks = latest.map((r) => enumRank.get(r.type) ?? -1);
expect(ranks).toEqual([...ranks].sort((a, b) => a - b));
const spark = await readSparkBuckets(
getPrismaClient(),
userId,
TYPES,
SPARK_DAYS,
);
for (const type of new Set(spark.map((r) => r.type))) {
const stamps = spark
.filter((r) => r.type === type)
.map((r) => r.bucket_start.getTime());
expect(stamps).toEqual([...stamps].sort((a, b) => a - b));
}
});
});
describe("dashboard all-time counts — parity with the live count", () => {
/** The pre-rollup read: one index entry per live reading. */
async function legacyAllTime(userId: string) {
const groups = await getPrismaClient().measurement.groupBy({
by: ["type"],
where: { userId, type: { in: TYPES }, deletedAt: null },
_count: { _all: true },
});
return new Map(groups.map((g) => [g.type, g._count._all]));
}
it("counts what the live count counts, from the rollups", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: { username: "all-time-counts", email: "all-time@example.test" },
});
const now = new Date();
const boundary = allTimeRollupBoundary(now);
// A watch-dense pulse history from two sources: a reading every four
// minutes for two years, written set-wise the way an import lands it.
const twoYears = 2 * 365 * DAY;
for (const [source, offsetMinutes] of [
["APPLE_HEALTH", 0],
["WITHINGS", 2],
] as const) {
await prisma.$executeRawUnsafe(
`INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
SELECT md5($1 || $2 || g::text), $1, 'PULSE'::"measurement_type",
60 + (g % 30), 'bpm', $2::"measurement_source",
$3::timestamptz + make_interval(mins => g * 4 + $4), now(), now()
FROM generate_series(0, $5::int) AS g`,
user.id,
source,
new Date(now.getTime() - twoYears),
offsetMinutes,
Math.floor(twoYears / (4 * 60_000)) - 1,
);
}
// Weigh-ins: some older than the fold window, one tombstoned.
await prisma.measurement.createMany({
data: [
...[1, 2, 3].map((years) => ({
userId: user.id,
type: "WEIGHT" as MeasurementType,
value: 80 + years,
unit: "kg",
measuredAt: new Date(boundary.getTime() - years * 30 * DAY),
})),
...[3, 2, 1].map((days) => ({
userId: user.id,
type: "WEIGHT" as MeasurementType,
value: 79,
unit: "kg",
measuredAt: new Date(now.getTime() - days * DAY),
deletedAt: days === 2 ? now : null,
})),
],
});
await recomputeUserRollups(user.id, { granularities: ["DAY"] });
// A type written after the fold, never bucketed: counted live.
await prisma.measurement.createMany({
data: [5, 4].map((days) => ({
userId: user.id,
type: "BLOOD_GLUCOSE" as MeasurementType,
value: 98,
unit: "mg/dL",
measuredAt: new Date(now.getTime() - days * DAY),
})),
});
let started = performance.now();
const legacy = await legacyAllTime(user.id);
const legacyMs = performance.now() - started;
started = performance.now();
const shipped = await readAllTimeCounts(prisma, user.id, TYPES, now);
const shippedMs = performance.now() - started;
console.log(
`all-time counts over ${legacy.get("PULSE")} pulse rows: live ${legacyMs.toFixed(0)} ms, rollups ${shippedMs.toFixed(0)} ms`,
);
expect(new Map(shipped.map((r) => [r.type, r.count]))).toEqual(legacy);
expect(legacy.get("PULSE")).toBeGreaterThan(500_000);
expect(legacy.get("WEIGHT")).toBe(5);
expect(legacy.get("BLOOD_GLUCOSE")).toBe(2);
// Over half a million rows written and folded into DAY buckets: about
// 35 s on a CI runner when the file ran alone. With three files sharing
// the runner's Postgres it took 1.7 times as long (measured with the
// database capped at two CPUs, HEALTHLOG_INTEGRATION_PG_CPUS=2: 14.6 s
// alone, 25.3 s beside the sharing matrix and the restore memory test),
// past the suite's 60 s default. The work is the point of the case, so
// the budget is sized to it: twice the contended estimate.
}, 150_000);
});