mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
282 lines
9.8 KiB
TypeScript
282 lines
9.8 KiB
TypeScript
/**
|
|
* #1023 — a status card over a dense heart-rate stream, measured.
|
|
*
|
|
* A single account syncing heart rate from a watch killed the worker within
|
|
* half an hour: the pulse status card's recent-window read was bounded in
|
|
* time (91 days) but not in rows, so every refresh materialised the whole
|
|
* stream as objects, and the read itself ran past the statement timeout.
|
|
* The read now folds per day in Postgres.
|
|
*
|
|
* The fixture is one reading a minute for 91 days, 131 040 rows, seeded in
|
|
* one `INSERT … generate_series`. Each half of the memory test reads the
|
|
* same window: the per-day fold the card now uses, and the raw `findMany` it
|
|
* used to issue. Without the second half the budget could be any number and
|
|
* the first half would still pass. Every reading of the heap is taken after a
|
|
* forced collection, so what is compared is what each read HOLDS; the
|
|
* transient figure (taken before collecting) is reported alongside.
|
|
*
|
|
* The parity case pins that the SQL fold and the in-memory fold the unit
|
|
* tests use agree on real Postgres: same days in a non-UTC zone, same
|
|
* segment edges, same plausibility filter.
|
|
*/
|
|
import v8 from "node:v8";
|
|
import vm from "node:vm";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { buildGradedSeriesWithRollups } from "@/lib/insights/graded-series";
|
|
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
|
import { foldDayAggregates } from "@/lib/measurements/__tests__/fake-day-aggregates";
|
|
import { localHmAsUtc } from "@/lib/tz/local-day";
|
|
import { dayValue } from "@/lib/measurements/day-mean";
|
|
import { userDayKey } from "@/lib/tz/format";
|
|
|
|
const prisma = getPrismaClient();
|
|
const DENSE_USER = "dense-pulse-owner";
|
|
const PARITY_USER = "dense-parity-owner";
|
|
const CLOCK_USER = "dense-clock-owner";
|
|
const DAYS = 91;
|
|
const ROWS = DAYS * 24 * 60;
|
|
const NOW = new Date("2026-09-20T18:00:00.000Z");
|
|
|
|
const forceGc = ((): (() => void) => {
|
|
v8.setFlagsFromString("--expose-gc");
|
|
const gc = vm.runInNewContext("gc") as () => void;
|
|
v8.setFlagsFromString("--no-expose-gc");
|
|
return gc;
|
|
})();
|
|
|
|
function liveHeapBytes(): number {
|
|
forceGc();
|
|
forceGc();
|
|
return process.memoryUsage().heapUsed;
|
|
}
|
|
|
|
const mb = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1);
|
|
|
|
function report(line: string): void {
|
|
process.stderr.write(`[dense-read] ${line}\n`);
|
|
}
|
|
|
|
/** Run `read`, returning what it holds, its transient peak and its time. */
|
|
async function measure<T>(read: () => Promise<T>): Promise<{
|
|
result: T;
|
|
heldBytes: number;
|
|
transientBytes: number;
|
|
ms: number;
|
|
}> {
|
|
const before = liveHeapBytes();
|
|
const t0 = performance.now();
|
|
const result = await read();
|
|
const ms = performance.now() - t0;
|
|
const transientBytes = process.memoryUsage().heapUsed - before;
|
|
const heldBytes = liveHeapBytes() - before;
|
|
return { result, heldBytes, transientBytes, ms };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: {
|
|
id: DENSE_USER,
|
|
username: DENSE_USER,
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
await prisma.$executeRawUnsafe(
|
|
`INSERT INTO measurements (
|
|
id, user_id, type, value, unit, source, measured_at,
|
|
created_at, updated_at, sync_version)
|
|
SELECT
|
|
'dp' || lpad(g::text, 8, '0'),
|
|
$1,
|
|
'PULSE'::measurement_type,
|
|
55 + (g % 50),
|
|
'bpm',
|
|
'GOOGLE_HEALTH'::measurement_source,
|
|
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
|
|
now(), now(), 1
|
|
FROM generate_series(1, $3::int) g`,
|
|
DENSE_USER,
|
|
NOW.toISOString(),
|
|
ROWS,
|
|
);
|
|
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
|
}, 300_000);
|
|
|
|
afterAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
});
|
|
|
|
describe("pulse status over a dense stream (#1023)", () => {
|
|
it("builds the graded series without materialising the stream", async () => {
|
|
const graded = await measure(() =>
|
|
buildGradedSeriesWithRollups(DENSE_USER, "PULSE", NOW, "Europe/Berlin"),
|
|
);
|
|
const series = graded.result;
|
|
// Every reading in the 91-day window is accounted for, one bucket per day
|
|
// or week, not one object per reading. The oldest reading sits exactly 91
|
|
// days back, which the graded split files under the older slice.
|
|
const counted = [...series.recent, ...series.weekly].reduce(
|
|
(sum, b) => sum + b.n,
|
|
0,
|
|
);
|
|
expect(counted).toBe(ROWS - 1);
|
|
expect(series.recent.length).toBeLessThanOrEqual(22);
|
|
expect(series.weekly.length).toBeLessThanOrEqual(12);
|
|
|
|
const raw = await measure(() =>
|
|
prisma.measurement.findMany({
|
|
where: {
|
|
userId: DENSE_USER,
|
|
type: "PULSE",
|
|
deletedAt: null,
|
|
measuredAt: { gte: new Date(NOW.getTime() - DAYS * 86_400_000) },
|
|
},
|
|
orderBy: { measuredAt: "asc" },
|
|
select: { measuredAt: true, value: true },
|
|
}),
|
|
);
|
|
expect(raw.result.length).toBe(ROWS);
|
|
|
|
report(
|
|
`graded series (per-day fold, cold tier): held ${mb(graded.heldBytes)} MB, ` +
|
|
`transient ${mb(graded.transientBytes)} MB, ${graded.ms.toFixed(0)} ms`,
|
|
);
|
|
report(
|
|
`raw 91-day findMany (the old read): held ${mb(raw.heldBytes)} MB, ` +
|
|
`transient ${mb(raw.transientBytes)} MB, ${raw.ms.toFixed(0)} ms, ` +
|
|
`${raw.result.length} rows`,
|
|
);
|
|
|
|
// The fold holds a few kilobytes; the raw read holds the stream. The
|
|
// transient figure is the one that killed the worker: the old builder
|
|
// held little once it returned but allocated the whole stream on the
|
|
// way (62 MB on this fixture, against 1.6 MB for the fold).
|
|
expect(graded.heldBytes).toBeLessThan(2 * 1024 * 1024);
|
|
expect(graded.transientBytes).toBeLessThan(12 * 1024 * 1024);
|
|
expect(raw.heldBytes).toBeGreaterThan(20 * Math.max(graded.heldBytes, 1));
|
|
}, 120_000);
|
|
|
|
it("the SQL fold agrees with the in-memory fold on real Postgres", async () => {
|
|
const tz = "Asia/Kolkata";
|
|
await prisma.user.create({
|
|
data: { id: PARITY_USER, username: PARITY_USER, timezone: tz },
|
|
});
|
|
const day = new Date("2026-09-10T12:00:00Z");
|
|
const edge = new Date(NOW.getTime() - 21 * 86_400_000);
|
|
const rows = [
|
|
{ at: localHmAsUtc(day, tz, 0, 5), value: 61 },
|
|
{ at: localHmAsUtc(day, tz, 23, 55), value: 99 },
|
|
{ at: localHmAsUtc(day, tz, 12, 0), value: 111_287_531 }, // impossible
|
|
{ at: edge, value: 70 },
|
|
{ at: new Date(edge.getTime() + 1), value: 72 },
|
|
{ at: new Date(NOW.getTime() - 200 * 86_400_000), value: 58 },
|
|
];
|
|
await prisma.measurement.createMany({
|
|
data: rows.map((r, i) => ({
|
|
id: `parity-${i}`,
|
|
userId: PARITY_USER,
|
|
type: "PULSE" as const,
|
|
value: r.value,
|
|
unit: "bpm",
|
|
source: "MANUAL" as const,
|
|
measuredAt: r.at,
|
|
})),
|
|
});
|
|
const opts = {
|
|
since: new Date(NOW.getTime() - 400 * 86_400_000),
|
|
until: NOW,
|
|
timeZone: tz,
|
|
valueRange: { min: 20, max: 300 },
|
|
segmentStarts: [edge, new Date(NOW.getTime() - 91 * 86_400_000)],
|
|
};
|
|
const sql = await readDayAggregates({
|
|
userId: PARITY_USER,
|
|
type: "PULSE",
|
|
...opts,
|
|
});
|
|
const memory = foldDayAggregates(
|
|
rows.map((r) => ({ measuredAt: r.at, value: r.value })),
|
|
opts,
|
|
);
|
|
// Pulse rows also carry the hourly-mean day (`day-mean.ts`); the plain
|
|
// fold fields agree with the in-memory fold, and each row's day value is
|
|
// the mean of its local hours' means over the same readings.
|
|
expect(
|
|
sql.map(
|
|
({ dayMean: _d, weightedSum: _w, weightSum: _s, ...plain }) => plain,
|
|
),
|
|
).toEqual(memory);
|
|
for (const row of sql) {
|
|
const members = rows.filter(
|
|
(r) =>
|
|
r.value >= 20 &&
|
|
r.value <= 300 &&
|
|
userDayKey(r.at, tz) === row.day &&
|
|
opts.segmentStarts.filter((s) => r.at < s).length === row.segment,
|
|
);
|
|
expect(row.dayMean).toBeCloseTo(
|
|
dayValue(
|
|
"PULSE",
|
|
members.map((r) => ({ value: r.value, measuredAt: r.at })),
|
|
tz,
|
|
)!,
|
|
9,
|
|
);
|
|
}
|
|
// Sanity: the impossible value was dropped and the edge row split off.
|
|
expect(sql.reduce((n, r) => n + r.n, 0)).toBe(5);
|
|
});
|
|
|
|
it("the local-clock cut agrees with the in-memory fold on real Postgres", async () => {
|
|
// A half-hour zone, so a cut on the UTC clock would land 30 minutes off.
|
|
const tz = "Asia/Kolkata";
|
|
await prisma.user.create({
|
|
data: { id: CLOCK_USER, username: CLOCK_USER, timezone: tz },
|
|
});
|
|
const days = [
|
|
new Date("2026-09-08T12:00:00Z"),
|
|
new Date("2026-09-09T12:00:00Z"),
|
|
new Date("2026-09-10T12:00:00Z"),
|
|
];
|
|
const rows = days.flatMap((d, i) => [
|
|
{ at: localHmAsUtc(d, tz, 7, 0), value: 85 + i },
|
|
{ at: localHmAsUtc(d, tz, 9, 59), value: 120 + i },
|
|
{ at: localHmAsUtc(d, tz, 10, 1), value: 160 + i }, // after the cut
|
|
{ at: localHmAsUtc(d, tz, 19, 0), value: 150 + i }, // after the cut
|
|
]);
|
|
await prisma.measurement.createMany({
|
|
data: rows.map((r, i) => ({
|
|
id: `clock-${i}`,
|
|
userId: CLOCK_USER,
|
|
type: "BLOOD_GLUCOSE" as const,
|
|
value: r.value,
|
|
unit: "mg/dL",
|
|
source: "MANUAL" as const,
|
|
measuredAt: r.at,
|
|
})),
|
|
});
|
|
const opts = {
|
|
since: new Date(NOW.getTime() - 400 * 86_400_000),
|
|
until: NOW,
|
|
timeZone: tz,
|
|
upToLocalTime: "10:00:00",
|
|
};
|
|
const sql = await readDayAggregates({
|
|
userId: CLOCK_USER,
|
|
type: "BLOOD_GLUCOSE",
|
|
...opts,
|
|
});
|
|
const memory = foldDayAggregates(
|
|
rows.map((r) => ({ measuredAt: r.at, value: r.value })),
|
|
opts,
|
|
);
|
|
expect(sql).toEqual(memory);
|
|
// Two readings per day survive the cut, the two after it do not.
|
|
expect(sql.map((r) => r.n)).toEqual([2, 2, 2]);
|
|
expect(sql.map((r) => r.max)).toEqual([120, 121, 122]);
|
|
});
|
|
});
|