mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
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, vi } 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]);
|
|
});
|
|
});
|