From 96b2ee4c1c9d71a4d535404e626918c8d4d56f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 11:37:40 +0200 Subject: [PATCH] perf(analytics): bound 15-way Promise.all to 4 concurrent type-fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 1 + pnpm-lock.yaml | 17 +++++++++++++++++ src/app/api/analytics/route.ts | 35 ++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/package.json b/package.json index af725755c..6ac41e0e7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10b724fc4..04a586533 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index e4e305f42..2f72b8171 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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 = {}; 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), }; }), + ), ), );