mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
test(analytics,db): pin concurrency cap and pool ceiling
Two regression nets for the W-POOL changes shipped in the prior two commits: * `src/lib/__tests__/db.test.ts` — assert `getPoolMax()` returns 20 by default and honours `DATABASE_POOL_MAX`, with a final assertion that pins the floor at 20 so a future refactor that drops the explicit `max:` config can't silently revert to the library default of 10. * `src/app/api/analytics/__tests__/route.test.ts` — instrument the per-type `findMany` mock with inflight counters and assert peak concurrency never exceeds `ANALYTICS_TYPE_FETCH_CONCURRENCY`. The wall-clock window also catches both an unbounded `Promise.all` regression (would finish too fast) and an accidental serialisation (would finish too slow).
This commit is contained in:
1 parent
014593a2a1
commit
433874e390
2 files changed
+132
-1
No files matched your search
@@ -73,7 +73,7 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { ANALYTICS_TYPE_FETCH_CONCURRENCY, GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
@@ -376,4 +376,71 @@ describe("GET /api/analytics", () => {
|
||||
expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeLessThanOrEqual(6);
|
||||
expect(body.data.lastSeenByType.PULSE).toBeNull();
|
||||
});
|
||||
|
||||
// v1.4.40 W-POOL — pin the bounded-concurrency cap on the per-type
|
||||
// fan-out inside `buildAnalyticsResponse`. The v1.4.39 empirical
|
||||
// trace (`.planning/round-v1439-empirical-trace.md` §B1) showed the
|
||||
// raw `Promise.all` over `fetchMeasurementSeriesChunked` holding
|
||||
// ≥8 of the default-10 pg.Pool slots for 6.5 s on a power-user cold
|
||||
// mount. The p-limit(4) wrapper keeps the same total work but caps
|
||||
// inflight Prisma round-trips for this branch at 4.
|
||||
//
|
||||
// The assertion instruments the `prisma.measurement.findMany` mock
|
||||
// to track peak inflight count + total batches. With N≥15 types and
|
||||
// a 20 ms per-call delay, an unbounded `Promise.all` would peak at
|
||||
// ~15 and finish in ~20 ms; the bounded version peaks at exactly 4
|
||||
// and finishes in ~80 ms (4 batches × 20 ms ceiling).
|
||||
it("caps per-type Prisma fan-out at ANALYTICS_TYPE_FETCH_CONCURRENCY", async () => {
|
||||
// Default-slice path requires no coverage so the route falls into
|
||||
// the live per-type loop where the fan-out lives.
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
|
||||
let inflight = 0;
|
||||
let peak = 0;
|
||||
let calls = 0;
|
||||
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||||
(async () => {
|
||||
// Only count the per-type chunked fan-out calls (not the
|
||||
// narrower glucose / sleep-stage `findMany` reads inside the
|
||||
// same route; those run sequentially AFTER the fan-out
|
||||
// completes so they never overlap with peak inflight).
|
||||
calls += 1;
|
||||
inflight += 1;
|
||||
peak = Math.max(peak, inflight);
|
||||
// 20 ms is large enough to make the bounded vs unbounded
|
||||
// distinction observable but small enough to keep the suite
|
||||
// under a second.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
inflight -= 1;
|
||||
// Return an empty page so the chunked reader exits after one
|
||||
// round-trip per type.
|
||||
return [] as never;
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const t0 = Date.now();
|
||||
const res = await (
|
||||
GET as unknown as (...args: never[]) => Promise<Response>
|
||||
)();
|
||||
const elapsed = Date.now() - t0;
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The cap itself — peak inflight Prisma reads against
|
||||
// `measurements` never crosses the configured ceiling.
|
||||
expect(ANALYTICS_TYPE_FETCH_CONCURRENCY).toBe(4);
|
||||
expect(peak).toBeLessThanOrEqual(ANALYTICS_TYPE_FETCH_CONCURRENCY);
|
||||
|
||||
// Sanity: more than one type was actually walked, otherwise the
|
||||
// bound is unfalsifiable.
|
||||
expect(calls).toBeGreaterThanOrEqual(8);
|
||||
|
||||
// Wall-clock proves the lanes are saturated rather than serialised
|
||||
// by accident. 4 lanes × 20 ms × ⌈calls/4⌉ ≈ 60-100 ms; an
|
||||
// unbounded fan-out would finish in ~25-40 ms; a fully serial
|
||||
// walk would take calls × 20 ms ≈ 300+ ms. The window catches
|
||||
// both regressions.
|
||||
expect(elapsed).toBeGreaterThanOrEqual(40);
|
||||
expect(elapsed).toBeLessThan(calls * 20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* v1.4.40 W-POOL — pool-config contract for `src/lib/db.ts`.
|
||||
*
|
||||
* The v1.4.39 empirical cold-mount trace
|
||||
* (`.planning/round-v1439-empirical-trace.md` § B2) traced the
|
||||
* Wave-B/C dashboard-query stall to the Prisma `pg.Pool` falling
|
||||
* back to the library default ceiling of 10 connections. W-POOL
|
||||
* pinned the ceiling to 20 via `getPoolMax()`; this test prevents a
|
||||
* future refactor from silently dropping the override and re-introducing
|
||||
* the saturation regression on a power-user cold mount.
|
||||
*
|
||||
* The test only exercises the `getPoolMax()` env-resolver. Constructing
|
||||
* a real `PrismaClient` requires a live `DATABASE_URL`, which would
|
||||
* make this an integration test instead of a unit test — the helper
|
||||
* isolation is intentional.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPoolMax } from "../db";
|
||||
|
||||
describe("getPoolMax", () => {
|
||||
const originalEnv = process.env.DATABASE_POOL_MAX;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.DATABASE_POOL_MAX;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.DATABASE_POOL_MAX;
|
||||
} else {
|
||||
process.env.DATABASE_POOL_MAX = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to 20 when DATABASE_POOL_MAX is unset", () => {
|
||||
expect(getPoolMax()).toBe(20);
|
||||
});
|
||||
|
||||
it("honours DATABASE_POOL_MAX when it parses to a positive int", () => {
|
||||
process.env.DATABASE_POOL_MAX = "30";
|
||||
expect(getPoolMax()).toBe(30);
|
||||
});
|
||||
|
||||
it("falls back to 20 on a malformed DATABASE_POOL_MAX", () => {
|
||||
process.env.DATABASE_POOL_MAX = "not-a-number";
|
||||
expect(getPoolMax()).toBe(20);
|
||||
});
|
||||
|
||||
it("falls back to 20 on a non-positive DATABASE_POOL_MAX", () => {
|
||||
process.env.DATABASE_POOL_MAX = "0";
|
||||
expect(getPoolMax()).toBe(20);
|
||||
process.env.DATABASE_POOL_MAX = "-5";
|
||||
expect(getPoolMax()).toBe(20);
|
||||
});
|
||||
|
||||
it("never returns the library default of 10 on the happy path", () => {
|
||||
// The whole point of the W-POOL change. If anybody removes the
|
||||
// `max:` config from `createPrismaClient` and reverts to the
|
||||
// library default, this assertion stops the regression before the
|
||||
// next saturated cold mount.
|
||||
expect(getPoolMax()).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user