diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index eaff09d66..1f7e52261 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -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 + )(); + 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); + }); }); diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts new file mode 100644 index 000000000..d433f534d --- /dev/null +++ b/src/lib/__tests__/db.test.ts @@ -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); + }); +});