mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(integration): hold statement budgets on the hot routes
tests/integration/query-counter.ts counts the statements the app sends while a function runs, by wrapping Client.prototype.query on the copy of pg the Prisma adapter itself resolves (so a second pg in node_modules cannot leave it reading zero; a window with no statement throws). Background tasks are settled before the window closes, so fire-and-forget writes count with the request that started them. query-budget-hot-routes.test.ts seeds a small account (four months of the main types with overlapping sources, stats rows, sleep, glucose, a medication, workouts and mood), warms each route once, evicts the user's caches and counts the next call. Budgets, measured on this fixture: /api/auth/me 18, dashboard summary 86, dashboard snapshot 103, pulse series 30/365/3650 days 6 each, day view 48, insights health status 60, correlations 44, a 500-row batch ingest 31. A failure prints every statement grouped by text. Proof: one extra `SELECT 1` before every handler in apiHandler failed all ten budgets by exactly one (18 → 19, 86 → 87, … 31 → 32), which also shows the counts are deterministic; with the probe removed all ten pass.
This commit is contained in:
1 parent
375ca12b05
commit
d640eea32a
2 files changed
+535
No files matched your search
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Statement budgets for the hot routes, against a real Postgres.
|
||||
*
|
||||
* Latency on a testcontainer says little; the number of statements a request
|
||||
* sends says a lot, and it is exact on a fixed fixture. A loop that reads per
|
||||
* row instead of per set (an N+1), a second read of something already loaded,
|
||||
* or a fan-out that grew with the data all show up here as a count over its
|
||||
* budget, on a change that every other test would let through.
|
||||
*
|
||||
* Each budget is the count measured on this fixture when it was written. A
|
||||
* route that needs fewer statements passes; lower the budget in the same
|
||||
* change so the gain is held. A route that needs more fails with every
|
||||
* statement it sent, grouped, so the new one is visible. Raising a budget is
|
||||
* a reviewed change with the reason beside the number.
|
||||
*
|
||||
* Every read runs on a miss: the user's caches are evicted before the
|
||||
* measured call, after one warm-up call that fills the process-level caches
|
||||
* a running server already holds. The large-account numbers (latency and
|
||||
* statements on 1.26 million rows) come from `scripts/bench/run.ts`; see
|
||||
* docs/contributing/benchmarks.md.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
Prisma,
|
||||
SleepStage,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { countStatements, summariseStatements } from "./query-counter";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
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 USER_ID = "user-query-budget";
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Statements per request on this fixture. Each is the measured count; see
|
||||
* the header for how to move one.
|
||||
*/
|
||||
const BUDGET = {
|
||||
authMe: 18,
|
||||
dashboardSummary: 86,
|
||||
dashboardSnapshot: 103,
|
||||
series30: 6,
|
||||
series365: 6,
|
||||
series3650: 6,
|
||||
dayView: 48,
|
||||
healthStatus: 60,
|
||||
correlations: 44,
|
||||
batchIngest500: 31,
|
||||
} as const;
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
context: { params: Promise<Record<string, string>> },
|
||||
) => Promise<Response>;
|
||||
|
||||
/** Midnight UTC `n` days before today. */
|
||||
function daysAgo(n: number): Date {
|
||||
const d = new Date(Date.now() - n * DAY_MS);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* A small account with the shape of a real one: dense and sparse types,
|
||||
* overlapping sources, cumulative `stats:` rows, sleep stages, glucose, a
|
||||
* medication with its intake history, workouts and mood.
|
||||
*/
|
||||
async function seed(): Promise<void> {
|
||||
const db = getPrismaClient();
|
||||
await db.user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "query-budget",
|
||||
email: "query-budget@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
const push = (
|
||||
type: MeasurementType,
|
||||
unit: string,
|
||||
source: MeasurementSource,
|
||||
at: Date,
|
||||
value: number,
|
||||
sleepStage?: SleepStage,
|
||||
) =>
|
||||
rows.push({
|
||||
userId: USER_ID,
|
||||
type,
|
||||
unit,
|
||||
source,
|
||||
measuredAt: at,
|
||||
value,
|
||||
externalId: `${source}-${type}-${at.getTime()}${sleepStage ?? ""}`,
|
||||
...(sleepStage ? { sleepStage } : {}),
|
||||
});
|
||||
|
||||
for (let d = 1; d <= 120; d++) {
|
||||
const day = daysAgo(d).getTime();
|
||||
for (let h = 0; h < 24; h += 2) {
|
||||
push(
|
||||
"PULSE",
|
||||
"bpm",
|
||||
"APPLE_HEALTH",
|
||||
new Date(day + h * 3_600_000),
|
||||
60 + ((d + h) % 20),
|
||||
);
|
||||
}
|
||||
push(
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"ms",
|
||||
"APPLE_HEALTH",
|
||||
new Date(day + 3 * 3_600_000),
|
||||
40 + (d % 15),
|
||||
);
|
||||
push(
|
||||
"RESTING_HEART_RATE",
|
||||
"bpm",
|
||||
"APPLE_HEALTH",
|
||||
new Date(day + 6 * 3_600_000),
|
||||
55 + (d % 5),
|
||||
);
|
||||
push(
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"mmHg",
|
||||
"WITHINGS",
|
||||
new Date(day + 7 * 3_600_000),
|
||||
120 + (d % 12),
|
||||
);
|
||||
push(
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"mmHg",
|
||||
"WITHINGS",
|
||||
new Date(day + 7 * 3_600_000),
|
||||
78 + (d % 8),
|
||||
);
|
||||
push(
|
||||
"PULSE",
|
||||
"bpm",
|
||||
"WITHINGS",
|
||||
new Date(day + 7 * 3_600_000),
|
||||
66 + (d % 6),
|
||||
);
|
||||
push(
|
||||
"WEIGHT",
|
||||
"kg",
|
||||
"WITHINGS",
|
||||
new Date(day + 6 * 3_600_000),
|
||||
82 + (d % 10) / 10,
|
||||
);
|
||||
if (d % 7 === 0) {
|
||||
push("WEIGHT", "kg", "MANUAL", new Date(day + 19 * 3_600_000), 82.3);
|
||||
}
|
||||
push(
|
||||
"OXYGEN_SATURATION",
|
||||
"%",
|
||||
"APPLE_HEALTH",
|
||||
new Date(day + 4 * 3_600_000),
|
||||
96 + (d % 3),
|
||||
);
|
||||
for (let s = 0; s < 8; s++) {
|
||||
push(
|
||||
"SLEEP_DURATION",
|
||||
"minutes",
|
||||
"APPLE_HEALTH",
|
||||
new Date(day - 3_600_000 + s * 45 * 60_000),
|
||||
45,
|
||||
(
|
||||
[
|
||||
"CORE",
|
||||
"DEEP",
|
||||
"REM",
|
||||
"CORE",
|
||||
"AWAKE",
|
||||
"CORE",
|
||||
"REM",
|
||||
"CORE",
|
||||
] as const
|
||||
)[s],
|
||||
);
|
||||
}
|
||||
const key = new Date(day).toISOString().slice(0, 10);
|
||||
rows.push({
|
||||
userId: USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date(day + 11 * 3_600_000),
|
||||
value: 7000 + (d % 9) * 300,
|
||||
externalId: `stats:HKQuantityTypeIdentifierStepCount:${key}`,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 7 * 96; i++) {
|
||||
push(
|
||||
"BLOOD_GLUCOSE",
|
||||
"mg/dL",
|
||||
"NIGHTSCOUT",
|
||||
new Date(daysAgo(7).getTime() + i * 15 * 60_000),
|
||||
95 + (i % 30),
|
||||
);
|
||||
}
|
||||
await db.measurement.createMany({ data: rows });
|
||||
|
||||
await db.workout.createMany({
|
||||
data: Array.from({ length: 20 }, (_, i) => ({
|
||||
userId: USER_ID,
|
||||
sportType: ["running", "walking", "cycling"][i % 3]!,
|
||||
startedAt: new Date(daysAgo(i * 3 + 1).getTime() + 17 * 3_600_000),
|
||||
endedAt: new Date(daysAgo(i * 3 + 1).getTime() + 18 * 3_600_000),
|
||||
durationSec: 3600,
|
||||
totalEnergyKcal: 400,
|
||||
source: "APPLE_HEALTH" as const,
|
||||
externalId: `workout-${i}`,
|
||||
})),
|
||||
});
|
||||
await db.moodEntry.createMany({
|
||||
data: Array.from({ length: 60 }, (_, i) => {
|
||||
const at = new Date(daysAgo(i + 1).getTime() + 18 * 3_600_000);
|
||||
return {
|
||||
userId: USER_ID,
|
||||
date: at.toISOString().slice(0, 10),
|
||||
mood: ["GUT", "OKAY", "SUPER_GUT"][i % 3]!,
|
||||
score: [4, 3, 5][i % 3]!,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: at,
|
||||
tz: "Europe/Berlin",
|
||||
};
|
||||
}),
|
||||
});
|
||||
const med = await db.medication.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: "Budget A",
|
||||
dose: "5 mg",
|
||||
schedules: { create: { windowStart: "08:00", windowEnd: "09:00" } },
|
||||
},
|
||||
});
|
||||
await db.medicationIntakeEvent.createMany({
|
||||
data: Array.from({ length: 60 }, (_, i) => {
|
||||
const at = new Date(daysAgo(i + 1).getTime() + 6 * 3_600_000);
|
||||
return {
|
||||
userId: USER_ID,
|
||||
medicationId: med.id,
|
||||
scheduledFor: at,
|
||||
takenAt: i % 10 === 0 ? null : new Date(at.getTime() + 300_000),
|
||||
skipped: i % 10 === 0,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const { recomputeUserRollups } =
|
||||
await import("@/lib/rollups/measurement-rollups");
|
||||
await recomputeUserRollups(USER_ID);
|
||||
|
||||
const session = await db.session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
async function evictUserCaches(): Promise<void> {
|
||||
const { invalidateUserMeasurements } = await import("@/lib/cache/invalidate");
|
||||
invalidateUserMeasurements(USER_ID, { evict: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a route once to fill the process-level caches, evict the user's, and
|
||||
* count the statements of a second call: the miss a reader meets after a
|
||||
* sync on a server that is already running.
|
||||
*/
|
||||
async function measureRead(
|
||||
handler: Handler,
|
||||
url: string,
|
||||
params: Record<string, string> = {},
|
||||
) {
|
||||
const call = () =>
|
||||
handler(new NextRequest(url), { params: Promise.resolve(params) });
|
||||
const warm = await call();
|
||||
expect(warm.status, `${url} warm-up`).toBe(200);
|
||||
await evictUserCaches();
|
||||
const measured = await countStatements(async () => {
|
||||
const res = await call();
|
||||
await res.arrayBuffer();
|
||||
return res.status;
|
||||
});
|
||||
expect(measured.result, url).toBe(200);
|
||||
return measured;
|
||||
}
|
||||
|
||||
function expectWithin(
|
||||
label: string,
|
||||
measured: { count: number; statements: string[] },
|
||||
budget: number,
|
||||
) {
|
||||
expect(
|
||||
measured.count,
|
||||
`${label}: ${measured.count} statements against a budget of ${budget}.\n` +
|
||||
summariseStatements(measured.statements),
|
||||
).toBeLessThanOrEqual(budget);
|
||||
}
|
||||
|
||||
describe("statement budgets on the hot routes (real Postgres)", () => {
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await seed();
|
||||
});
|
||||
|
||||
it("GET /api/auth/me", async () => {
|
||||
const { GET } = await import("@/app/api/auth/me/route");
|
||||
const m = await measureRead(
|
||||
GET as unknown as Handler,
|
||||
"http://localhost/api/auth/me",
|
||||
);
|
||||
expectWithin("/api/auth/me", m, BUDGET.authMe);
|
||||
});
|
||||
|
||||
it("GET /api/dashboard/summary", async () => {
|
||||
const { GET } = await import("@/app/api/dashboard/summary/route");
|
||||
const m = await measureRead(
|
||||
GET as unknown as Handler,
|
||||
"http://localhost/api/dashboard/summary",
|
||||
);
|
||||
expectWithin("/api/dashboard/summary", m, BUDGET.dashboardSummary);
|
||||
});
|
||||
|
||||
it("GET /api/dashboard/snapshot", async () => {
|
||||
const { GET } = await import("@/app/api/dashboard/snapshot/route");
|
||||
const m = await measureRead(
|
||||
GET as unknown as Handler,
|
||||
"http://localhost/api/dashboard/snapshot",
|
||||
);
|
||||
expectWithin("/api/dashboard/snapshot", m, BUDGET.dashboardSnapshot);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[30, BUDGET.series30],
|
||||
[365, BUDGET.series365],
|
||||
[3650, BUDGET.series3650],
|
||||
])("GET /api/measurements/series, %i days", async (days, budget) => {
|
||||
const { GET } = await import("@/app/api/measurements/series/route");
|
||||
const m = await measureRead(
|
||||
GET as unknown as Handler,
|
||||
`http://localhost/api/measurements/series?kind=pulse&days=${days}`,
|
||||
);
|
||||
expectWithin(`/api/measurements/series ${days} d`, m, budget);
|
||||
});
|
||||
|
||||
it("GET /api/day/[date]", async () => {
|
||||
const { GET } = await import("@/app/api/day/[date]/route");
|
||||
const date = daysAgo(2).toISOString().slice(0, 10);
|
||||
const m = await measureRead(
|
||||
GET as unknown as Handler,
|
||||
`http://localhost/api/day/${date}`,
|
||||
{ date },
|
||||
);
|
||||
expectWithin("/api/day/[date]", m, BUDGET.dayView);
|
||||
});
|
||||
|
||||
it("GET /api/insights/health-status", async () => {
|
||||
const { GET } = await import("@/app/api/insights/health-status/route");
|
||||
const m = await measureRead(
|
||||
GET as unknown as Handler,
|
||||
"http://localhost/api/insights/health-status",
|
||||
);
|
||||
expectWithin("/api/insights/health-status", m, BUDGET.healthStatus);
|
||||
});
|
||||
|
||||
it("GET /api/insights/correlations", async () => {
|
||||
const { GET } = await import("@/app/api/insights/correlations/route");
|
||||
const m = await measureRead(
|
||||
GET as unknown as Handler,
|
||||
"http://localhost/api/insights/correlations",
|
||||
);
|
||||
expectWithin("/api/insights/correlations", m, BUDGET.correlations);
|
||||
});
|
||||
|
||||
it("POST /api/measurements/batch, 500 fresh rows", async () => {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
const start = daysAgo(3).getTime() + 30_000;
|
||||
const entries = Array.from({ length: 500 }, (_, i) => {
|
||||
const at = new Date(start + i * 60_000).toISOString();
|
||||
return {
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
||||
value: 55 + (i % 40),
|
||||
unit: "count/min",
|
||||
startDate: at,
|
||||
endDate: at,
|
||||
externalId: `budget-hr-${i}`,
|
||||
};
|
||||
});
|
||||
const m = await countStatements(async () => {
|
||||
const res = await (POST as unknown as Handler)(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ entries }),
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
);
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as { data: { inserted: number } },
|
||||
};
|
||||
});
|
||||
expect(m.result.status).toBe(200);
|
||||
expect(m.result.body.data.inserted).toBe(500);
|
||||
expectWithin(
|
||||
"POST /api/measurements/batch (500)",
|
||||
m,
|
||||
BUDGET.batchIngest500,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Counts the SQL statements the application sends to Postgres while a
|
||||
* function runs.
|
||||
*
|
||||
* Every statement the Prisma client sends goes through `pg`'s
|
||||
* `Client.prototype.query` (the driver adapter's pool hands out `pg`
|
||||
* clients, raw queries included, transactions' BEGIN/COMMIT included). The
|
||||
* prototype is patched once, on the `pg` instance the adapter itself
|
||||
* resolves, so a second copy of `pg` elsewhere in node_modules cannot leave
|
||||
* the counter reading zero; `countStatements` refuses a window that saw no
|
||||
* statement at all for the same reason.
|
||||
*
|
||||
* Background work a request starts and does not await (the fire-and-forget
|
||||
* tasks) is settled before the window closes, so it is counted with the
|
||||
* request that caused it.
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
interface PgClientPrototype {
|
||||
query: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
const requireHere = createRequire(import.meta.url);
|
||||
const adapterEntry = requireHere.resolve("@prisma/adapter-pg");
|
||||
const pg = createRequire(adapterEntry)("pg") as {
|
||||
Client: { prototype: PgClientPrototype };
|
||||
};
|
||||
|
||||
const state = { active: false, statements: [] as string[] };
|
||||
const PATCHED = Symbol.for("healthlog.queryCounter");
|
||||
|
||||
const proto = pg.Client.prototype as PgClientPrototype & {
|
||||
[PATCHED]?: boolean;
|
||||
};
|
||||
if (!proto[PATCHED]) {
|
||||
const original = proto.query;
|
||||
proto.query = function patchedQuery(this: unknown, ...args: unknown[]) {
|
||||
if (state.active) {
|
||||
const first = args[0] as string | { text?: string } | undefined;
|
||||
const text = typeof first === "string" ? first : (first?.text ?? "?");
|
||||
state.statements.push(text.replace(/\s+/g, " ").trim().slice(0, 160));
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
proto[PATCHED] = true;
|
||||
}
|
||||
|
||||
export interface StatementCount<T> {
|
||||
result: T;
|
||||
count: number;
|
||||
statements: string[];
|
||||
}
|
||||
|
||||
export async function countStatements<T>(
|
||||
fn: () => Promise<T>,
|
||||
): Promise<StatementCount<T>> {
|
||||
const { settleBackgroundTasks } =
|
||||
await import("@/lib/logging/background-tasks");
|
||||
await settleBackgroundTasks();
|
||||
state.statements = [];
|
||||
state.active = true;
|
||||
let result: T;
|
||||
try {
|
||||
result = await fn();
|
||||
await settleBackgroundTasks();
|
||||
} finally {
|
||||
state.active = false;
|
||||
}
|
||||
const statements = state.statements;
|
||||
if (statements.length === 0) {
|
||||
throw new Error(
|
||||
"countStatements saw no statement at all. Either the call reads " +
|
||||
"nothing from Postgres, or the counter is patched on a different " +
|
||||
"copy of pg than the one the Prisma adapter uses.",
|
||||
);
|
||||
}
|
||||
return { result, count: statements.length, statements };
|
||||
}
|
||||
|
||||
/** The statements grouped by text, most frequent first, for a failure message. */
|
||||
export function summariseStatements(statements: readonly string[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of statements) counts.set(s, (counts.get(s) ?? 0) + 1);
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([s, n]) => `${String(n).padStart(4)} × ${s}`)
|
||||
.join("\n");
|
||||
}
|
||||
Reference in new issue
Block a user