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

+69
View File
@@ -2484,6 +2484,40 @@ paths:
"401": *a1
"422": *a2
"429": *a3
/api/insights/derived/batch:
get:
tags:
- Insights
summary: Derived wellness metrics (batched compute-once)
description: v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a
`metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the
profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out
of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The
single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.
parameters:
- in: query
name: metrics
schema:
type: string
minLength: 1
maxLength: 1024
description: Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or
`<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id
422s; at most 24 tokens; duplicates collapse.
required: true
description: Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or
`<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s;
at most 24 tokens; duplicates collapse.
responses:
"200":
description: The map of derived-metric values, keyed by token.
content:
application/json:
schema:
$ref: "#/components/schemas/DerivedBatchResponseEnvelope"
"401": *a1
"422": *a2
"429": *a3
/api/insights/correlations:
get:
tags:
@@ -7720,6 +7754,41 @@ components:
- windowDays
- computedAt
additionalProperties: false
DerivedBatchResponseEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/DerivedBatchResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
DerivedBatchResponse:
type: object
properties:
metrics:
type: object
propertyNames:
type: string
additionalProperties:
$ref: "#/components/schemas/DerivedMetricResponse"
description: Map keyed by the per-request token (`<metric>` or `<metric>:<type>`). Each value is the same flat
`Derived<T>` envelope the single-metric route returns, so a client decodes one shape and reads back exactly
the tokens it asked for.
required:
- metrics
additionalProperties: false
description: Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the
derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma
pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table.
CorrelationDiscoveryResponseEnvelope:
type: object
properties:
@@ -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,
});
@@ -88,3 +88,87 @@ export function useDerivedMetric<T>(
retry: 0,
});
}
/**
* A batch request token: a metric id with an optional VITALS_BASELINE
* sub-target. Wire form is `metric` or `metric:type` inside the CSV.
*/
export interface DerivedBatchToken {
metric: string;
type?: string | null;
}
/** Build the `metric` / `metric:type` wire token for one batch item. */
function tokenString(token: DerivedBatchToken): string {
return token.type ? `${token.metric}:${token.type}` : token.metric;
}
/** The batch route's `data` shape — a map keyed by the per-request token. */
export interface DerivedBatchResponse {
metrics: Record<string, DerivedMetricResponse<unknown>>;
}
/**
* The typed `read` selector `useDerivedBatch` returns — a tile narrows its
* own metric value out of the resolved map exactly as it did per-metric.
*/
export type DerivedBatchRead = <T>(
token: DerivedBatchToken,
) => DerivedMetricResponse<T> | null;
/**
* v1.10.0 — ONE batched read for a set of derived metrics, the dashboard's
* cold-mount fan-out fix. Replaces N independent `useDerivedMetric` queries
* (each a separate request sharing the Prisma pool — the v1.9.1 "hangs then
* recovers" symptom) with a single `GET /api/insights/derived/batch`. The
* server fans out under a bounded limiter and loads the profile once.
*
* Returns a typed `read(token)` selector so a tile pulls its own
* `DerivedMetricResponse<T>` out of the map with the same narrowing it had
* per-metric. Same 8 s ceiling + `retry:0` + 60 s `staleTime` as the single
* hook; these reads never warm/generate on visit.
*/
export function useDerivedBatch(
tokens: DerivedBatchToken[],
options: { enabled?: boolean } = {},
) {
const { enabled = true } = options;
const { isAuthenticated } = useAuth();
const wireTokens = tokens.map(tokenString);
const gated = enabled && isAuthenticated && wireTokens.length > 0;
const query = useQuery({
queryKey: queryKeys.insightsDerivedBatch(wireTokens),
queryFn: async (): Promise<DerivedBatchResponse> => {
const controller = new AbortController();
const timeoutHandle = setTimeout(
() => controller.abort(),
DERIVED_TIMEOUT_MS,
);
try {
const params = new URLSearchParams({ metrics: wireTokens.join(",") });
const res = await fetch(
`/api/insights/derived/batch?${params.toString()}`,
{ signal: controller.signal },
);
if (!res.ok) {
throw new Error(`derived batch request failed (${res.status})`);
}
return (await res.json()).data as DerivedBatchResponse;
} finally {
clearTimeout(timeoutHandle);
}
},
enabled: gated,
staleTime: 60_000,
retry: 0,
});
/** Pull one metric's value out of the resolved map, narrowed to `T`. */
function read<T>(token: DerivedBatchToken): DerivedMetricResponse<T> | null {
const entry = query.data?.metrics[tokenString(token)];
return (entry as DerivedMetricResponse<T> | undefined) ?? null;
}
return { ...query, read };
}
+1
View File
@@ -55,6 +55,7 @@ export type { NormRange, NormSex } from "./norms";
// ── server-only compute engines (do NOT value-import from a client component) ──
export {
computeVitalsBaseline,
loadBaselineProfile,
buildBaselineBand,
median,
medianAbsoluteDeviation,
+55
View File
@@ -1406,6 +1406,36 @@ const derivedMetricResponse = z
"Flat `Derived<T>` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.",
});
// v1.10.0 — batched derived-metric query. The `metrics` CSV carries one
// or more `metric` / `metric:type` tokens; the route fans out server-side
// under a bounded limiter with the profile loaded once, collapsing the
// dashboard's cold-mount fan-out of N single-metric requests into one.
const derivedBatchQuery = z
.object({
metrics: z
.string()
.min(1)
.max(1024)
.describe(
"Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; at most 24 tokens; duplicates collapse.",
),
})
.meta({ id: "DerivedBatchQuery" });
const derivedBatchResponse = z
.object({
metrics: z
.record(z.string(), derivedMetricResponse)
.describe(
"Map keyed by the per-request token (`<metric>` or `<metric>:<type>`). Each value is the same flat `Derived<T>` envelope the single-metric route returns, so a client decodes one shape and reads back exactly the tokens it asked for.",
),
})
.meta({
id: "DerivedBatchResponse",
description:
"Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table.",
});
// v1.10.0 — FDR-controlled correlation discovery result. One discovered,
// statistically-defensible behaviour → next-day-outcome pair.
const discoveredCorrelation = z
@@ -3190,6 +3220,31 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/insights/derived/batch": {
get: {
tags: ["Insights"],
summary: "Derived wellness metrics (batched compute-once)",
description:
"v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.",
requestParams: {
query: derivedBatchQuery,
},
responses: {
"200": {
description: "The map of derived-metric values, keyed by token.",
content: {
"application/json": {
schema: dataEnvelope(
derivedBatchResponse,
"DerivedBatchResponseEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/correlations": {
get: {
tags: ["Insights"],
+10
View File
@@ -158,6 +158,16 @@ export const queryKeys = {
*/
insightsDerived: (metric: string, type?: string | null) =>
["insights", "derived", metric, type ?? null] as const,
/**
* v1.10.0 — batched derived-metric read (`/api/insights/derived/batch`).
* One request resolves the whole dashboard grid server-side instead of N
* concurrent single-metric reads sharing the Prisma pool. Keyed by the
* sorted token list so the same grid always hits one cache entry; sits
* under the `["insights", "derived"]` prefix so a measurement-write
* invalidation fan-out reaches it exactly like the single-metric reads.
*/
insightsDerivedBatch: (tokens: readonly string[]) =>
["insights", "derived", "batch", [...tokens].sort().join(",")] as const,
/**
* v1.10.0 — FDR-controlled correlation discovery
* (`/api/insights/correlations`). Read-only descriptive surface; the