Files
HealthLog/tests/integration/pr-detection-cumulative-paged.test.ts
T
Marc-André Bombeck 77bfa2f4e1 perf(records): find the best cumulative day a day at a time
The personal-record pass, every half hour for every account, read every
live row of each cumulative type into memory to find the best day. With
784 000 active-energy rows on one account (the per-sample rows pile up
when the nightly drain cannot finish, #1031) it ran a 524 MB heap out of
memory; the old code needed 1.1 GB of heap for that account.

It now reads the type in bounded keyset pages and handles one local day
at a time. The canonical-source pick it applies is per day, so the day
totals and the winner are the same; a tie still keeps the earliest day.
Measured on the same account: 189 MB peak heap, the same record. The
warm-up gate counts only up to its threshold instead of counting a whole
history.

`iterateSourcePages` and `iterateDayBuckets` in the consolidation base
take the row type as a parameter, and the day grouping can keep every row.
2026-09-25 02:05:19 +02:00

112 lines
3.7 KiB
TypeScript

/**
* #1031 — the best cumulative day, found a day at a time.
*
* `findBestCumulativeDay` read every row of a cumulative type into memory,
* every half hour, for every account. On an account holding 784 000 active
* energy rows it ran a 524 MB heap out of memory. It now reads a local day at
* a time in time order, which gives the same answer because the canonical-
* source pick it applies is per day.
*
* Pinned here at a size that spans several read pages: two sources report
* the same days, the source ladder must decide each day on its own, the
* best day sits in the middle of the history, and no read of the history is
* unbounded.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const { detectPersonalRecordsForUser } =
await import("@/lib/personal-records/pr-detection-worker");
const USER = "user-pr-cumulative-paged";
const DAYS = 40;
const PER_DAY = 300; // 12 000 Apple rows plus a Withings total per day
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: USER,
username: USER,
email: `${USER}@example.test`,
timezone: "UTC",
},
});
});
describe("best cumulative day across many pages", () => {
it("sums each day's canonical source and finds the best day without an unbounded read", async () => {
const prisma = getPrismaClient();
const start = Date.UTC(2025, 0, 1, 6);
const rows = [];
for (let day = 0; day < DAYS; day++) {
// Day 23 is the best Apple day; the Withings total on day 5 is larger
// still but loses to Apple on that day by the default ladder, so it
// must not win: if the days were pooled across sources it would.
const perSample = day === 23 ? 40 : 20;
for (let i = 0; i < PER_DAY; i++) {
rows.push({
userId: USER,
type: "ACTIVITY_STEPS" as const,
value: perSample,
unit: "steps",
source: "APPLE_HEALTH" as const,
measuredAt: new Date(start + day * 86_400_000 + i * 60_000),
externalId: `hk-${day}-${i}`,
});
}
rows.push({
userId: USER,
type: "ACTIVITY_STEPS" as const,
value: day === 5 ? 50_000 : 1_000,
unit: "steps",
source: "WITHINGS" as const,
measuredAt: new Date(start + day * 86_400_000 + 12 * 3_600_000),
externalId: `w-${day}`,
});
}
await prisma.measurement.createMany({ data: rows });
// Every read of the steps history, through a client that records it.
const reads: Array<number | undefined> = [];
const recording = prisma.$extends({
query: {
measurement: {
findMany({ args, query }) {
if (JSON.stringify(args.where ?? {}).includes("ACTIVITY_STEPS")) {
reads.push(args.take);
}
return query(args);
},
},
},
});
await detectPersonalRecordsForUser(USER, {
silent: true,
prisma: recording as unknown as typeof prisma,
});
const record = await prisma.personalRecord.findFirstOrThrow({
where: { userId: USER, metricType: "ACTIVITY_STEPS" },
});
expect(record.value).toBe(40 * PER_DAY);
expect(record.achievedAt.toISOString()).toBe(
new Date(start + 23 * 86_400_000 + (PER_DAY - 1) * 60_000).toISOString(),
);
// Several bounded pages, never one read of the whole history.
expect(reads.length).toBeGreaterThan(1);
for (const take of reads) {
expect(take).toBeDefined();
expect(take!).toBeLessThanOrEqual(5_000);
}
});
});