Files
HealthLog/tests/integration/measurements-batch-perf.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
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.
2026-11-03 20:32:35 +01:00

252 lines
8.3 KiB
TypeScript

/**
* `POST /api/measurements/batch` under a realistic large batch.
*
* Production saw this route take 2 s and more, up to 15 s, on full
* 500-entry uploads. The cost was one interactive reconciler transaction
* (about ten round trips) per fresh sample. Since v1.44 fresh samples land
* through a set-based `INSERT … ON CONFLICT DO NOTHING` and only rows that
* hit an existing identity reach the reconciler.
*
* This suite pins the shape of the work rather than a machine's speed:
* - a fresh 500-entry batch opens a handful of transactions, not 500;
* - a re-post of the same batch opens none (the duplicate prefilter);
* - the rollup leg runs once per request, not once per row;
* - the wall-clock bound is generous, a tripwire for a regression back to
* per-row work, not a benchmark.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-batch-perf-test";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
const rollupCalls = vi.hoisted(() => ({ count: 0 }));
vi.mock("@/lib/rollups/after-measurement-mutation", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/after-measurement-mutation")
>("@/lib/rollups/after-measurement-mutation");
return {
...actual,
afterMeasurementMutation: (
...args: Parameters<typeof actual.afterMeasurementMutation>
) => {
rollupCalls.count += 1;
return actual.afterMeasurementMutation(...args);
},
};
});
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
rollupCalls.count = 0;
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "batch-perf",
email: "batch-perf@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
/**
* What a phone sends after a day offline: dense heart rate, a few HRV and
* oxygen readings, a night of sleep stages, and the day's cumulative totals.
*/
function realisticBatch(dayOffset = 2): Array<Record<string, unknown>> {
const day = new Date(Date.now() - dayOffset * 86_400_000);
day.setUTCHours(0, 0, 0, 0);
const at = (minutes: number) =>
new Date(day.getTime() + minutes * 60_000).toISOString();
const entries: Array<Record<string, unknown>> = [];
for (let i = 0; i < 400; i++) {
entries.push({
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 55 + (i % 40),
unit: "count/min",
startDate: at(i * 3),
endDate: at(i * 3),
externalId: `perf-hr-${dayOffset}-${i}`,
});
}
for (let i = 0; i < 40; i++) {
entries.push({
hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
value: 30 + i,
unit: "ms",
startDate: at(i * 30 + 1),
endDate: at(i * 30 + 1),
externalId: `perf-hrv-${dayOffset}-${i}`,
});
entries.push({
hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation",
value: 0.96,
unit: "%",
startDate: at(i * 30 + 2),
endDate: at(i * 30 + 2),
externalId: `perf-spo2-${dayOffset}-${i}`,
});
}
for (let i = 0; i < 16; i++) {
entries.push({
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 30,
unit: "min",
startDate: at(-480 + i * 30),
endDate: at(-450 + i * 30),
externalId: `perf-sleep-${dayOffset}-${i}`,
sleepStage: 3 + (i % 3),
});
}
const dayKey = day.toISOString().slice(0, 10);
for (const [hk, unit, value] of [
["HKQuantityTypeIdentifierStepCount", "count", 9_431],
["HKQuantityTypeIdentifierActiveEnergyBurned", "kcal", 512],
["HKQuantityTypeIdentifierAppleExerciseTime", "min", 42],
["HKQuantityTypeIdentifierAppleStandTime", "min", 180],
] as const) {
entries.push({
hkIdentifier: hk,
value,
unit,
startDate: at(0),
endDate: at(18 * 60),
externalId: `stats:${hk}:${dayKey}`,
});
}
return entries;
}
async function post(entries: Array<Record<string, unknown>>) {
const { POST } = await import("@/app/api/measurements/batch/route");
const res = await POST(
new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ entries }),
}),
);
expect(res.status).toBe(200);
return (
(await res.json()) as {
data: {
inserted: number;
updated: number;
duplicates: number;
failed: number;
skipped: number;
};
}
).data;
}
describe("POST /api/measurements/batch — large-batch cost (real Postgres)", () => {
it("writes a fresh 500-entry batch in a handful of transactions", async () => {
const { prisma } = await import("@/lib/db");
const entries = realisticBatch();
expect(entries).toHaveLength(500);
const transaction = vi.spyOn(prisma, "$transaction");
const started = performance.now();
const result = await post(entries);
const elapsedMs = performance.now() - started;
const transactions = transaction.mock.calls.length;
transaction.mockRestore();
expect(result.failed).toBe(0);
expect(result.inserted).toBe(500);
// Two set statements (250 rows each) plus the hour-bucket and checkpoint
// writes. Before v1.44 this was one transaction per row: 500.
expect(transactions).toBeLessThanOrEqual(10);
expect(rollupCalls.count).toBe(1);
expect(
await getPrismaClient().measurement.count({
where: { userId: TEST_USER_ID },
}),
).toBe(500);
// A tripwire, not a benchmark: per-row work on a local container sits
// well above this, the set path well below.
expect(elapsedMs).toBeLessThan(10_000);
console.info(
`[batch-perf] fresh 500: ${Math.round(elapsedMs)} ms, ${transactions} transactions`,
);
});
it("answers a re-post of the same batch without a transaction", async () => {
const { prisma } = await import("@/lib/db");
const entries = realisticBatch();
await post(entries);
rollupCalls.count = 0;
const transaction = vi.spyOn(prisma, "$transaction");
const started = performance.now();
const result = await post(entries);
const elapsedMs = performance.now() - started;
const transactions = transaction.mock.calls.length;
transaction.mockRestore();
// The four `stats:` rows carry overwrite semantics and reconcile; every
// sample is an exact duplicate and never reaches a transaction.
expect(result.inserted).toBe(0);
expect(result.duplicates + result.updated).toBe(500);
expect(transactions).toBeLessThanOrEqual(8);
expect(elapsedMs).toBeLessThan(10_000);
console.info(
`[batch-perf] re-post 500: ${Math.round(elapsedMs)} ms, ${transactions} transactions`,
);
});
it("keeps the set path's verdicts when the batch overlaps stored rows", async () => {
const entries = realisticBatch();
await post(entries.slice(0, 250));
// Move half of the stored heart-rate rows: same id, another instant.
const moved = entries.slice(0, 100).map((e) => ({
...e,
startDate: new Date(
Date.parse(e.endDate as string) + 1_000,
).toISOString(),
endDate: new Date(Date.parse(e.endDate as string) + 1_000).toISOString(),
}));
const result = await post([...moved, ...entries.slice(250)]);
expect(result.failed).toBe(0);
expect(result.updated).toBeGreaterThanOrEqual(100);
expect(result.inserted).toBe(250);
expect(
await getPrismaClient().measurement.count({
where: { userId: TEST_USER_ID, deletedAt: null },
}),
).toBe(500);
});
});