perf(analytics): cap WMY per-type fan-out at 4 to close 9s regression

`computeAvg30LastYearMap` in the slim summaries slice ran an unbounded
`Promise.all` over every type the user had data for. On a 15-type
tenant the burst held 15+ Prisma slots simultaneously, and with the
slim and thick `/api/analytics` slices firing in parallel on dashboard
mount the combined fan-out drowned the pg.Pool max=20 raised in
v1.4.40 W-POOL — landing a ~9s server-side wait on every cold mount
since v1.4.40 W-WMY-WIRE.

Cap the per-type fan-out with `p-limit(4)`, mirroring the W-POOL
discipline v1.4.40 applied to the thick route's
`ANALYTICS_TYPE_FETCH_CONCURRENCY`. Same total work, at most 4 slots
held at any moment, leaving headroom for the concurrent thick slice.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 21:43:55 +02:00
1 parent 2c68a48d21
commit c9a54154cb
1 file changed
+27 -8
+27 -8
View File
@@ -53,6 +53,8 @@
* 100`. Slope `direction` is derived from `slope`'s sign with the
* same 0.01-units-per-day "stable" threshold the JS helper uses.
*/
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
@@ -724,24 +726,41 @@ async function computeAvg30LastYearForType(
* in parallel so the long-tail of types doesn't serialise into a
* per-type round-trip stack.
*
* v1.4.43 — cap concurrent per-type WMY reads at 4.
*
* Pre-fix this helper ran an unbounded Promise.all over every type the
* user has data for. On a 15-type tenant the burst held 15+ Prisma
* slots simultaneously, and with the slim and thick analytics slices
* firing in parallel on dashboard mount the combined fan-out drowned
* the pg.Pool max=20 even after the v1.4.40 W-POOL raise. The cap
* mirrors the W-POOL `p-limit(4)` discipline v1.4.40 applied to the
* thick route's per-type live walk (`ANALYTICS_TYPE_FETCH_CONCURRENCY`
* in `src/app/api/analytics/route.ts`) so both slices behave the same
* way under burst.
*
* Returns a `type → mean | null` map; types with no coverage at any
* granularity (or no buckets in the year-ago slice) map to `null` so
* the caller can blanket-assign without per-type null-checking.
*/
async function computeAvg30LastYearMap(
export const WMY_FANOUT_CONCURRENCY = 4;
export async function computeAvg30LastYearMap(
userId: string,
types: ReadonlyArray<string>,
): Promise<Map<string, number | null>> {
const out = new Map<string, number | null>();
if (types.length === 0) return out;
const limit = pLimit(WMY_FANOUT_CONCURRENCY);
const results = await Promise.all(
types.map(async (type) => {
const value = await computeAvg30LastYearForType(
userId,
type as MeasurementType,
);
return [type, value] as const;
}),
types.map((type) =>
limit(async () => {
const value = await computeAvg30LastYearForType(
userId,
type as MeasurementType,
);
return [type, value] as const;
}),
),
);
for (const [type, value] of results) {
out.set(type, value);