Files
HealthLog/tests/integration/measurements-batch-perf.test.ts
T
Marc-André Bombeck 846b0e5df0 perf(measurements): insert fresh batch samples set-based; answer health while starting
POST /api/measurements/batch ran one interactive reconciler transaction
per fresh sample: a savepoint, three advisory locks, two candidate reads
and the insert, about ten round trips. A 500-entry upload of new samples
was 500 of those, the 2 to 15 second outliers. Fresh samples now land
through INSERT ... ON CONFLICT DO NOTHING under the same per-account
advisory lock. Both measurement identities are full unique indexes, so
the statement inserts exactly what the reconciler would have inserted,
and every skipped row still goes through the reconciler for its own
verdict. Only rows whose external and natural keys each occur once in
the batch are buffered, and the buffer is flushed before any other row
is reconciled, so the order of effects stays the sequential one. The
contract and every per-entry status are unchanged; a golden test runs
each batch through both paths against the same table.

/api/health took seconds after a start because register() awaited the
whole pg-boss worker start (queues, schedules, boot discovery) before
the server took a request. The worker, or a web process's producer, now
starts in the background after the blocking boot checks; the probe
answers at once with reason "starting" until it is up, and gives the
database two seconds before calling it not answering.
2026-10-21 20:42:06 +02:00

256 lines
8.5 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);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
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);
});
});