From 67a72b10239b71da2e9fc24235159718d9ad1ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 31 May 2026 22:42:11 +0200 Subject: [PATCH] perf(insights): parallelise the nightly per-user assessment warm pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warm pass forced the seven per-metric status generators strictly back to back per user, so a fully configured account paid ~7× one LLM round-trip serially. Bound the inner pass with a small `p-limit(3)` so the cards warm a few at a time without saturating the Prisma pool or fanning out an unbounded number of provider calls. The per-user budget gate still bounds total cost per night. --- src/lib/jobs/insight-pregenerate.ts | 40 +++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index bbfd9c2bd..e70f9f3b3 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -45,6 +45,8 @@ * Recurring pg-boss task — never runs inside an HTTP request and never * shells out to `tsx` (CLAUDE.md DO-NOTs). */ +import pLimit from "p-limit"; + import type { PrismaClient } from "@/generated/prisma/client"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAssistantFlags } from "@/lib/feature-flags"; @@ -81,6 +83,17 @@ export const PREGENERATE_BATCH_CAP = 200; /** Per-user budget bucket window — one pre-generation per 20 h. */ const PREGENERATE_BUDGET_WINDOW_MS = 20 * 60 * 60 * 1000; +/** + * Concurrency for the per-user warm pass. The seven `*StatusForUser` + * generators are independent (each writes its own cache row), so they run + * a few at a time rather than strictly serially — a daily-weigher with + * every card configured was paying ~7× one LLM round-trip per user back + * to back. Capped low (3) so the warm pass cannot saturate the Prisma + * pool or fan out an unbounded number of concurrent provider calls; the + * per-user budget gate above still bounds total cost per night. + */ +const WARM_PASS_CONCURRENCY = 3; + export interface InsightPregeneratePayload { triggeredAt: string; } @@ -146,17 +159,22 @@ async function warmPerStatusCaches( locale: "de" | "en", generators: ReadonlyArray, ): Promise { - let warmed = 0; - for (const generate of generators) { - try { - const outcome = await generate(userId, { locale, force: true }); - if (outcome.hasProvider && !outcome.cached) warmed++; - } catch { - // A single card's generation failing must not abort the rest of - // the warm pass for this user, nor the cron's user loop. - } - } - return warmed; + const limit = pLimit(WARM_PASS_CONCURRENCY); + const results = await Promise.all( + generators.map((generate) => + limit(async () => { + try { + const outcome = await generate(userId, { locale, force: true }); + return outcome.hasProvider && !outcome.cached ? 1 : 0; + } catch { + // A single card's generation failing must not abort the rest of + // the warm pass for this user, nor the cron's user loop. + return 0; + } + }), + ), + ); + return results.reduce((sum: number, n) => sum + n, 0); } interface PregenerateCandidate {