perf(analytics): bound 15-way Promise.all to 4 concurrent type-fetches

Wrap the per-type fan-out in `buildAnalyticsResponse` with a p-limit(4)
gate. The empirical v1.4.39 cold-mount trace
(`.planning/round-v1439-empirical-trace.md` §B1) showed thick
/api/analytics holding ≥8 of the default-10 pg.Pool slots for 6.5 s on a
power-user account, starving every other dashboard query. The cap keeps
the same total work but holds ≤4 pool slots for this branch at any
moment, leaving the remaining slots free for the parallel chart-tile
reads. Thick analytics is already 60 s cache-wrapped so the small
absolute wall-clock cost on a single cold mount is offset by the
chart-tile burst paying ~5 s less of pool-wait time on every mount
thereafter.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 11:37:40 +02:00
1 parent c9d5479b0d
commit 96b2ee4c1c
3 files changed
+53

No files matched your search

+1
View File
@@ -66,6 +66,7 @@
"mmdb-lib": "^3.0.2",
"next": "16.2.6",
"next-themes": "^0.4.6",
"p-limit": "^7.3.0",
"pg-boss": "^12.18.2",
"prisma": "^7.8.0",
"radix-ui": "^1.4.3",
+17
View File
@@ -65,6 +65,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
p-limit:
specifier: ^7.3.0
version: 7.3.0
pg-boss:
specifier: ^12.18.2
version: 12.18.2
@@ -4808,6 +4811,10 @@ packages:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-limit@7.3.0:
resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
engines: {node: '>=20'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@@ -6060,6 +6067,10 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
yocto-queue@1.2.2:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
yocto-spinner@1.2.0:
resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==}
engines: {node: '>=18.19'}
@@ -10991,6 +11002,10 @@ snapshots:
dependencies:
yocto-queue: 0.1.0
p-limit@7.3.0:
dependencies:
yocto-queue: 1.2.2
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
@@ -12445,6 +12460,8 @@ snapshots:
yocto-queue@0.1.0: {}
yocto-queue@1.2.2: {}
yocto-spinner@1.2.0:
dependencies:
yoctocolors: 2.1.2
+35
View File
@@ -1,3 +1,4 @@
import pLimit from "p-limit";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -27,6 +28,32 @@ import {
export const dynamic = "force-dynamic";
/**
* v1.4.40 W-POOL — bound the per-type analytics fan-out below to ≤ 4
* concurrent Prisma round-trips.
*
* The v1.4.39 empirical cold-mount trace
* (`.planning/round-v1439-empirical-trace.md` § B1) recorded thick
* `/api/analytics` running 15× `fetchMeasurementSeriesChunked` in
* parallel via a bare `Promise.all` (this file's `route.ts:261-269`
* pre-fix), each pulling chunked findMany pages of 5 000 rows. The
* combined fan-out held ≥ 8 of the default-10 `pg.Pool` slots for
* 6.5 s on Marc's 347k-row tenant, starving every other Wave-B / C
* dashboard query.
*
* Capping the concurrency to 4 keeps the same total work but holds
* at most 4 pool slots for the analytics branch at any moment. With
* the W-POOL ceiling raised to 20 (`src/lib/db.ts:getPoolMax`),
* every other dashboard query has ≥ 16 free slots regardless of how
* many types analytics is mid-walk over. The thick endpoint pays a
* small (~10–15 %) absolute wall-clock cost on a single cold mount —
* 15 chunks across 4 lanes = 4 batches instead of 1 — but is already
* wrapped in the 60s `caches.analytics` LRU so the user-visible
* impact is only the very first mount, in exchange for the chart-tile
* burst paying ~5 s less of pool wait time on every mount thereafter.
*/
export const ANALYTICS_TYPE_FETCH_CONCURRENCY = 4;
/**
* v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the
* route's production wrapper) or a plain `Request` (the integration
@@ -258,8 +285,15 @@ async function buildAnalyticsResponse(user: AuthedUser) {
const lastSeenByType: Record<string, { lastSeenAt: string } | null> = {};
let totalRowsReadForAggregate = 0;
// v1.4.40 W-POOL — bounded-concurrency wrapper for the 15-way
// per-type fan-out below. See `ANALYTICS_TYPE_FETCH_CONCURRENCY`
// above + `.planning/round-v1439-empirical-trace.md` § B1 for the
// pool-saturation root cause. Same total work, ≤ 4 pool slots held
// by this branch at any moment.
const typeFetchLimit = pLimit(ANALYTICS_TYPE_FETCH_CONCURRENCY);
const measurementsByType = await Promise.all(
types.map((type) =>
typeFetchLimit(() =>
fetchMeasurementSeriesChunked(user.id, type, {
includeSleepStage: true,
// v1.4.39 W-SINCE — trailing 425-day floor for the live read.
@@ -348,6 +382,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
summary: summarize(datapoints),
};
}),
),
),
);