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

@@ -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 };
}