feat(insights): batch the derived-metric reads behind one route

The Insights cold mount fired 14+ independent GET /api/insights/derived
requests — Readiness, Sleep and the vitals baselines are compute-on-visit,
and all of them share one Prisma pool. That is the pool-starvation class
that surfaces as the app hanging then recovering.

Add GET /api/insights/derived/batch?metrics=a,b,c[:type] that resolves the
requested metrics server-side under a bounded p-limit(4) with the profile
loaded once via the shared loadBaselineProfile, returning a map of the same
flat Derived<T> envelope keyed by the request token. Add the useDerivedBatch
hook + the insightsDerivedBatch query-key factory entry; the single-metric
route + hook stay for the per-score detail pages. The single route now also
adopts loadBaselineProfile instead of re-inlining the profile read. Honors
the existing 8s ceiling, retry:0, replace-with-fallback, and warms nothing
on visit. Register the new route in the OpenAPI contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 00:04:56 +02:00
1 parent 3f8c8e750c
commit b92d5cec82
8 files changed
+561 -16

No files matched your search

@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
user: {
findUnique: vi.fn().mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: "MALE",
heightCm: 180,
}),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the compute layer; keep the real registry + loadBaselineProfile.
vi.mock("@/lib/insights/derived", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/insights/derived")>();
return {
...actual,
computeDerivedMetric: vi.fn(async (args: { metric: string }) => ({
status: "ok",
value: { metric: args.metric, n: 1 },
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
confidence: { score: 100, band: "high" },
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
})),
};
});
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { computeDerivedMetric } from "@/lib/insights/derived";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(metrics?: string): NextRequest {
const url = new URL("http://localhost/api/insights/derived/batch");
if (metrics !== undefined) url.searchParams.set("metrics", metrics);
return new NextRequest(url);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: "MALE",
heightCm: 180,
} as never);
});
describe("GET /api/insights/derived/batch", () => {
it("resolves several metrics into one keyed map", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(
makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { metrics: Record<string, { metric: string; status: string }> };
error: string | null;
};
expect(body.error).toBeNull();
expect(Object.keys(body.data.metrics).sort()).toEqual([
"BMI",
"READINESS",
"VITALS_BASELINE:WEIGHT",
]);
expect(body.data.metrics["VITALS_BASELINE:WEIGHT"].status).toBe("ok");
expect(body.data.metrics.READINESS.metric).toBe("READINESS");
});
it("loads the profile once regardless of metric count", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("READINESS,BMI,FITNESS_AGE,HRV_BALANCE"));
// One User read for the whole batch — the pool-contention mitigation.
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
expect(computeDerivedMetric).toHaveBeenCalledTimes(4);
});
it("narrows userId from the session into every compute call", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("READINESS"));
expect(computeDerivedMetric).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", metric: "READINESS" }),
);
});
it("collapses a duplicate token so it cannot inflate the fan-out", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("BMI,BMI,BMI"));
expect(computeDerivedMetric).toHaveBeenCalledTimes(1);
});
it("422s when any token is an unknown metric id", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("READINESS,NOT_A_METRIC"));
expect(res.status).toBe(422);
expect(computeDerivedMetric).not.toHaveBeenCalled();
});
it("422s on a missing metrics param", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(422);
expect(computeDerivedMetric).not.toHaveBeenCalled();
});
it("422s on an empty metrics list", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq(" , , "));
expect(res.status).toBe(422);
expect(computeDerivedMetric).not.toHaveBeenCalled();
});
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq("READINESS"));
expect(res.status).toBe(401);
});
});
+190
View File
@@ -0,0 +1,190 @@
/**
* v1.10.0 — batched derived-wellness-metric route.
*
* `GET /api/insights/derived/batch?metrics=<id,id,…>[&types=<type,…>]`
* resolves several derived metrics in ONE request and returns a map keyed
* by the per-request key (`<metric>` or `<metric>:<type>` for a
* VITALS_BASELINE sub-target). It exists to collapse the Insights cold
* mount's 14+ independent `GET /api/insights/derived` requests — each
* sharing one Prisma pool — into a single request that fans out
* server-side under a bounded `p-limit`, the exact pool-starvation class
* that bit v1.4.49.1 / v1.4.40 (the "app hangs then recovers" symptom).
*
* Honors the same contract as the single route: closed registry enum
* (unknown id → 422), cookie OR Bearer auth, `userId` narrowed from the
* session (never a query field), pure compute over the rollup tier (no
* provider call, no cache table). The profile is loaded ONCE via the
* shared `loadBaselineProfile` and threaded into every compute, so the
* batch never re-reads the `User` row per metric.
*
* The single-metric route stays for the anatomy detail pages (one metric
* per page); this route backs the dashboard's one-shot grid read.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import pLimit from "p-limit";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import {
computeDerivedMetric,
loadBaselineProfile,
isDerivedMetricId,
type DerivedMetricId,
} from "@/lib/insights/derived";
export const dynamic = "force-dynamic";
// Bounded internal fan-out — matches the W-POOL `p-limit(4)` discipline
// v1.4.40 applied to the per-type analytics walk so a batch of 14 metrics
// never floods the shared Prisma pool.
const BATCH_CONCURRENCY = 4;
// Defensive ceiling — the dashboard requests ~11 metrics + ~6 baseline
// sub-targets; cap the list so a crafted query can't fan out unbounded.
const MAX_METRICS = 24;
/**
* A single batch request item: a registered metric id with an optional
* VITALS_BASELINE sub-target. The wire form is `metric` or `metric:type`
* inside the `metrics` CSV; an entry's `type` is parsed off the colon.
*/
interface BatchItem {
metric: DerivedMetricId;
type: string | null;
/** The map key the response is keyed by (mirrors the request token). */
key: string;
}
const batchQuerySchema = z.object({
metrics: z
.string()
.min(1)
.max(1024)
.describe("CSV of derived-metric tokens (`metric` or `metric:type`)."),
});
/**
* Parse the `metrics` CSV into validated items. An entry `metric:type`
* sub-targets a VITALS_BASELINE vital; a bare `metric` carries no type.
* Unknown ids are returned as `invalid` so the route 422s the whole
* request rather than silently dropping a metric (the closed-enum rule).
* Duplicate keys collapse to one item (last write wins) so a repeated
* token can't inflate the fan-out.
*/
function parseBatchItems(csv: string): {
items: BatchItem[];
invalid: string[];
} {
const seen = new Map<string, BatchItem>();
const invalid: string[] = [];
for (const raw of csv.split(",")) {
const token = raw.trim();
if (token === "") continue;
const colon = token.indexOf(":");
const metric = colon === -1 ? token : token.slice(0, colon);
const type = colon === -1 ? null : token.slice(colon + 1) || null;
if (!isDerivedMetricId(metric)) {
invalid.push(metric);
continue;
}
const key = type ? `${metric}:${type}` : metric;
seen.set(key, { metric, type, key });
}
return { items: [...seen.values()], invalid };
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const parsed = batchQuerySchema.safeParse({
metrics: request.nextUrl.searchParams.get("metrics"),
});
if (!parsed.success) {
annotate({
action: { name: "insights.derived.batch.invalid-query" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const { items, invalid } = parseBatchItems(parsed.data.metrics);
if (invalid.length > 0) {
// Mirror the single route's closed-enum rule — an unknown id is a
// contract violation, not a silent drop. The id name is the caller's
// own input, never user health data, so it is safe to echo.
annotate({
action: { name: "insights.derived.batch.invalid-metric" },
meta: { invalid_count: invalid.length },
});
return apiError(
`Unknown derived metric id(s): ${invalid.join(", ")}`,
422,
);
}
if (items.length === 0 || items.length > MAX_METRICS) {
annotate({
action: { name: "insights.derived.batch.invalid-count" },
meta: { requested: items.length, max: MAX_METRICS },
});
return apiError(
items.length === 0
? "metrics must name at least one registered derived metric"
: `metrics must name at most ${MAX_METRICS} entries`,
422,
);
}
// Profile read ONCE — the pool-contention mitigation; the same shared
// loader the nightly score jobs use, so the batch never re-reads the
// User row per metric.
const profile = await loadBaselineProfile(prisma, user.id);
// Fan out under a bounded limiter so a 17-item dashboard read resolves
// four computes at a time against the shared pool, not seventeen.
const limit = pLimit(BATCH_CONCURRENCY);
const now = new Date();
const results = await Promise.all(
items.map((item) =>
limit(async () => {
const derived = await computeDerivedMetric({
metric: item.metric,
userId: user.id,
profile,
type: item.type,
now,
});
return {
key: item.key,
payload: {
metric: item.metric,
status: derived.status,
value: derived.status === "ok" ? derived.value : null,
coverage: derived.coverage,
confidence: derived.status === "ok" ? derived.confidence : null,
provenance: derived.provenance,
reason:
derived.status === "insufficient" ? derived.reason : null,
},
};
}),
),
);
const map: Record<string, (typeof results)[number]["payload"]> = {};
for (const r of results) map[r.key] = r.payload;
annotate({
action: { name: "insights.derived.batch" },
meta: {
requested: items.length,
ok: results.filter((r) => r.payload.status === "ok").length,
},
});
// `metrics` is a map keyed by the per-request token so the client reads
// back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`).
return apiSuccess({ metrics: map });
});
+6 -16
View File
@@ -25,9 +25,9 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import {
computeDerivedMetric,
loadBaselineProfile,
DERIVED_METRIC_IDS,
type DerivedMetricId,
} from "@/lib/insights/derived";
@@ -63,25 +63,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const metric = parsed.data.metric as DerivedMetricId;
// Profile read once, passed into the pure compute function (never
// re-fetched per metric — the pool-contention mitigation).
const profile = await prisma.user.findUnique({
where: { id: user.id },
select: { dateOfBirth: true, gender: true, heightCm: true },
});
const sex =
profile?.gender === "MALE" || profile?.gender === "FEMALE"
? profile.gender
: null;
// Profile read once via the shared loader (the same one the batch route
// and the nightly score jobs use), passed into the pure compute function
// — never re-fetched per metric.
const profile = await loadBaselineProfile(prisma, user.id);
const derived = await computeDerivedMetric({
metric,
userId: user.id,
profile: {
ageYears: getAgeFromDateOfBirth(profile?.dateOfBirth ?? null),
sex,
heightCm: profile?.heightCm ?? null,
},
profile,
type: parsed.data.type ?? null,
});