perf(api): chunked BP aggregate replaces unbounded findMany

The W2-of-v1.4.20 BD-Zielbereich fix needed the complete sys + dia
series to compute the all-time + prior-month + prior-year windows,
so it pulled both tables via single-shot `findMany`. For a 5-year
power user (~9 000 rows per side) that's a 50-100 ms Prisma round-
trip and a ~2 MB working-set allocation per /api/analytics request.

Switch to a cursor-paged 5 000-row chunked read via the new
`fetchBpSeriesChunked()` helper. The accumulated `BpReading[]` is the
same shape `computeBpInTargetWindows()` already consumed; the
working-set ceiling is now `BP_CHUNK_SIZE` instead of unbounded. Safety
bound at 1 000 pages = 5 M rows so a cursor-staleness bug can't loop
forever.

Wide-event annotation `analytics.bp_in_target.row_count` (+ per-side
`sys_rows` / `dia_rows`) lets ops attribute slow requests to outlier
users without rerunning the DB queries.

Integration test seeds a 6 000-row dataset (3 000 sys + 3 000 dia)
spanning a chunk boundary and asserts the response shape stays the
same — last30Days, last7Days, allTime windows all carry the
hand-derivable percentages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 01:21:20 +02:00
1 parent 58ae9bca42
commit fa0774835e
2 files changed
+160 -8

No files matched your search

+75 -8
View File
@@ -126,17 +126,34 @@ export const GET = apiHandler(async () => {
// all-time as a 3-line sub-row so power users still see the
// long-arc number without it dominating. The helper still returns
// every window — only the headline pick changed.
//
// v1.4.23 H2 — chunked aggregation replaces an unbounded findMany.
// The W2-of-v1.4.20 fix did the right thing semantically (all-time
// window for the headline) but read the entire BP table into one
// array per type. A 5-year power user holds ~9 000 rows × 2; the
// single-shot fetch produced a 50-100 ms Prisma round-trip plus a
// ~2 MB allocation per request. Page through in 5 000-row chunks
// so the working set stays bounded; accumulate into the same
// `BpReading[]` shape the existing helper expects. The
// `analytics.bp_in_target.row_count` wide-event meta lets ops
// attribute slow requests to specific outlier users.
const [sysData, diaData] = await Promise.all([
prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
select: { measuredAt: true, value: true },
}),
prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" },
select: { measuredAt: true, value: true },
}),
fetchBpSeriesChunked(user.id, "BLOOD_PRESSURE_SYS"),
fetchBpSeriesChunked(user.id, "BLOOD_PRESSURE_DIA"),
]);
annotate({
meta: {
analytics: {
bp_in_target: {
row_count: sysData.length + diaData.length,
sys_rows: sysData.length,
dia_rows: diaData.length,
},
},
},
});
const windows = computeBpInTargetWindows(sysData, diaData, bpTargets, now);
bpInTargetPct = windows.last30Days?.pct ?? null;
bpInTargetPct7d = windows.last7Days?.pct ?? null;
@@ -414,6 +431,56 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
return { bpCompliance, moodPulse, weightWeekday };
}
/**
* v1.4.23 H2 — paged read of every BP measurement for a single user.
*
* The W2-of-v1.4.20 BD-Zielbereich fix relies on the full historical
* series (the helper needs every paired reading to compute the all-time
* + prior-month + prior-year windows). A single-shot `findMany` works
* for users with hundreds of rows but a 5-year tenant carries ~9 000
* sys rows + ~9 000 dia rows; the route allocated a ~2 MB working set
* per request. Page through `BP_CHUNK_SIZE` rows at a time so the live
* memory footprint stays bounded — the per-row payload is still small
* enough that the final `BpReading[]` survives, but we never have two
* full copies of the table in flight at once.
*
* The cursor is `(measuredAt, id)` to break ties when two rows share
* the same timestamp (manual entries imported in bulk); the result
* order matches the existing single-shot `orderBy: { measuredAt: "asc" }`
* the helper assumed.
*/
const BP_CHUNK_SIZE = 5000;
async function fetchBpSeriesChunked(
userId: string,
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA",
): Promise<Array<{ measuredAt: Date; value: number }>> {
const out: Array<{ measuredAt: Date; value: number }> = [];
let cursorId: string | undefined;
// Loop with a safety bound — at 5 000/page the bound only triggers
// for >5 000 pages = >25 M rows, far beyond the per-user plausibility
// range. Defence-in-depth against an infinite-loop bug on cursor
// staleness.
for (let page = 0; page < 1000; page++) {
const chunk = await prisma.measurement.findMany({
where: { userId, type },
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: { id: true, measuredAt: true, value: true },
take: BP_CHUNK_SIZE,
...(cursorId
? { cursor: { id: cursorId }, skip: 1 }
: {}),
});
if (chunk.length === 0) break;
for (const row of chunk) {
out.push({ measuredAt: row.measuredAt, value: row.value });
}
if (chunk.length < BP_CHUNK_SIZE) break;
cursorId = chunk[chunk.length - 1].id;
}
return out;
}
// v1.4.22 W5 reconcile (Code-MED-3) — `berlinDayKey()` lifted to
// `src/lib/analytics/berlin-day.ts` so the targets route's sparkline
// bucketing shares the same Europe/Berlin contract. The