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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 02:05:19 +02:00
1 parent 237acc0002
commit 77bfa2f4e1
3 files changed
+197 -55

No files matched your search

+21 -12
View File
@@ -315,12 +315,14 @@ const CONSOLIDATION_SCAN_PAGE_SIZE = 5000;
* The caller's `baseWhere` is AND-combined, so the scope / soft-delete /
* grace filters always still apply.
*/
export async function* iterateSourcePages(
export async function* iterateSourcePages<
Row extends { id: string; measuredAt: Date } = PerSampleRow,
>(
prismaClient: PrismaClient,
baseWhere: Prisma.MeasurementWhereInput,
scanSelect: Prisma.MeasurementSelect,
pageSize: number = CONSOLIDATION_SCAN_PAGE_SIZE,
): AsyncGenerator<PerSampleRow[], void, void> {
): AsyncGenerator<Row[], void, void> {
let cursor: { measuredAt: Date; id: string } | null = null;
for (;;) {
@@ -344,7 +346,7 @@ export async function* iterateSourcePages(
select: scanSelect,
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
take: pageSize,
})) as PerSampleRow[];
})) as unknown as Row[];
if (page.length === 0) return;
@@ -364,22 +366,29 @@ export async function* iterateSourcePages(
*
* The rows arrive in `measuredAt` order and a local calendar day is one
* contiguous span of instants, so a day is complete the moment a row from a
* later day shows up. Rows already in the daily-stats shape are skipped, the
* same as `bucketRowsByDay`. `onRow` sees every scanned row, stats rows
* included, for the scan-count log line.
* later day shows up. Rows whose externalId starts with `statsPrefix` (the
* daily-stats shape) are skipped, the same as `bucketRowsByDay`; pass `null`
* to keep every row. `onRow` sees every scanned row, skipped ones included,
* for the scan-count log line.
*/
export async function* iterateDayBuckets(
pages: AsyncIterable<readonly PerSampleRow[]>,
export async function* iterateDayBuckets<
Row extends { measuredAt: Date; externalId: string | null } = PerSampleRow,
>(
pages: AsyncIterable<readonly Row[]>,
tz: string,
statsPrefix: string,
statsPrefix: string | null,
onRow?: () => void,
): AsyncGenerator<[dateKey: string, rows: PerSampleRow[]], void, void> {
): AsyncGenerator<[dateKey: string, rows: Row[]], void, void> {
let currentKey: string | null = null;
let current: PerSampleRow[] = [];
let current: Row[] = [];
for await (const page of pages) {
for (const row of page) {
onRow?.();
if (row.externalId !== null && row.externalId.startsWith(statsPrefix)) {
if (
statsPrefix !== null &&
row.externalId !== null &&
row.externalId.startsWith(statsPrefix)
) {
continue;
}
const key = dayKeyForUserTz(row.measuredAt, tz);
+65 -43
View File
@@ -40,6 +40,10 @@
import { prisma as defaultPrisma } from "@/lib/db";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import {
iterateDayBuckets,
iterateSourcePages,
} from "@/lib/measurements/consolidation-base";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { measurementTypeEnum } from "@/lib/validations/measurement";
@@ -273,8 +277,12 @@ export async function detectPersonalRecordsForUser(
// Warm-up gate. Cheap count first so we don't pull a 30k-row
// scan on a brand-new account.
// Bounded: the gate only asks whether there are enough, so it stops
// counting at the threshold instead of counting a whole history (766 000
// heart-rate rows on a large account, every half hour).
const sampleCount = await prisma.measurement.count({
where: { userId, type: metricType, deletedAt: null },
take: PR_DETECTION_WARMUP_THRESHOLD,
});
if (sampleCount < PR_DETECTION_WARMUP_THRESHOLD) continue;
@@ -542,18 +550,28 @@ async function findBestMeasurement(
* cumulative-day surface.
*
* Order of operations:
* 1. Read every row for the type (already scoped by the caller's
* warm-up gate to a metric with at least
* `PR_DETECTION_WARMUP_THRESHOLD` samples).
* 2. Collapse to the canonical source per local day.
* 3. Bucket-and-sum the canonical rows per local day; pick the
* winning day by `direction`.
* 1. Read the type's rows one local day at a time, in time order
* (already scoped by the caller's warm-up gate to a metric with at
* least `PR_DETECTION_WARMUP_THRESHOLD` samples).
* 2. Collapse each day to its canonical source.
* 3. Sum the day's canonical rows; keep the winning day by `direction`.
* 4. Re-read the winning day's latest slice so the PR row carries a
* real row's unit/source/externalId (the day-sum overrides
* `value` below).
* The unique-index contract is unaffected — a re-run picks the same
* day and the same latest-slice timestamp, so `achievedAt` is stable.
*/
/** One row of the cumulative-day scan. */
interface CumulativeRow {
id: string;
value: number;
unit: string;
measuredAt: Date;
source: MeasurementSource;
externalId: string | null;
deviceType: string | null;
}
async function findBestCumulativeDay(
prisma: PrismaClient,
userId: string,
@@ -562,46 +580,50 @@ async function findBestCumulativeDay(
tz: string,
priorityJson: unknown,
): Promise<MeasurementCandidate | null> {
const rows = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: {
id: true,
value: true,
unit: true,
measuredAt: true,
source: true,
externalId: true,
deviceType: true,
},
});
if (rows.length === 0) return null;
// Read a day at a time, in time order, never the whole history: the
// canonical-source pick is per day, so running it on each day alone gives
// the same rows it gave on the whole list. Loading every row at once was
// unbounded; with 784 000 rows of one cumulative type the pass ran a
// 524 MB heap out of memory (#1031).
const metricKey = metricKeyForType(type);
const canonicalRows = metricKey
? pickCanonicalSourceRows(
rows.map((r) => ({ ...r, type })),
metricKey,
priorityJson,
(d) => dayKeyForUserTz(d, tz),
).canonicalRows
: rows;
const byDay = new Map<string, { total: number; latest: Date }>();
for (const row of canonicalRows) {
const key = dayKeyForUserTz(row.measuredAt, tz);
const slot = byDay.get(key);
if (!slot) {
byDay.set(key, { total: row.value, latest: row.measuredAt });
} else {
slot.total += row.value;
if (row.measuredAt > slot.latest) slot.latest = row.measuredAt;
}
}
let winnerTotal: number | null = null;
let winnerLatest: Date | null = null;
for (const { total, latest } of byDay.values()) {
const days = iterateDayBuckets(
iterateSourcePages<CumulativeRow>(
prisma,
{ userId, type, deletedAt: null },
{
id: true,
value: true,
unit: true,
measuredAt: true,
source: true,
externalId: true,
deviceType: true,
},
),
tz,
null,
);
for await (const [, dayRows] of days) {
const canonicalRows = metricKey
? pickCanonicalSourceRows(
dayRows.map((r) => ({ ...r, type })),
metricKey,
priorityJson,
(d) => dayKeyForUserTz(d, tz),
).canonicalRows
: dayRows;
if (canonicalRows.length === 0) continue;
let total = 0;
let latest = canonicalRows[0]!.measuredAt;
for (const row of canonicalRows) {
total += row.value;
if (row.measuredAt > latest) latest = row.measuredAt;
}
// Days arrive in order, and only a strictly better day replaces the
// winner, so a tie keeps the earliest day, as before.
const better =
winnerTotal === null ||
(direction === PersonalRecordDirection.MAX