perf(insights,api): always answer instantly and regenerate in the background

The insights read path served a cold cache by generating inline — up to
ten seconds on first visit — and the nightly warm pass structurally
excluded evening visitors: their visit stamped the cache, the half-four
run skipped it as fresh, and it expired exactly at the next evening's
visit. Stale content now serves immediately with its timestamp while
one background job revalidates, the warm threshold drops to an hour,
forced warming fills both message locales, the analytics caches serve
stale-while-revalidate, the profile endpoint parallelises its reads,
the bug-report status drops a write per call, the umami proxy degrades
to a no-op script instead of an error, the insights route sheds its
heaviest static imports, and the medication page prefetches on intent.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 19:05:49 +02:00
1 parent ceeb818f41
commit 78fe82e348
19 files changed
+319 -101

No files matched your search

@@ -500,7 +500,7 @@ describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () =>
});
describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
it("warms ONLY the active locale, bypasses the budget gate, and tallies counts", async () => {
it("warms BOTH locale families, bypasses the budget gate, and tallies counts", async () => {
const { prisma } = makePrisma([]);
const generate = vi
.fn()
@@ -516,27 +516,31 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
warmGenericMetrics,
});
// Comprehensive forced once, in the active locale only. The forced path
// also threads an AbortSignal so a timeout can cut the generation off.
// Comprehensive forced once, in the caller's active locale. The forced
// path also threads an AbortSignal so a timeout can cut the generation
// off.
expect(generate).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledWith("u1", {
locale: "en",
force: true,
signal: expect.any(AbortSignal),
});
// Each specialised generator forced exactly once — the active locale,
// NOT both de+en like the nightly cron.
// v1.16.7 — each specialised generator runs once PER locale family
// (de+en, like the nightly cron): the comprehensive write above
// evicted both families, so refilling only one left the other cold.
// The generated comprehensive means eviction happened → force: true.
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
expect(g).toHaveBeenCalledTimes(2);
expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true });
expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: true });
}
// Generic warm pass runs for the single active locale.
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["en"]);
// Generic warm pass covers both locale families too.
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], true);
// No 20 h budget bucket consulted on the forced path.
expect(checkRateLimit).not.toHaveBeenCalled();
expect(result).toMatchObject({
comprehensive: "generated",
assessmentsWarmed: 7,
assessmentsWarmed: 14,
metricAssessmentsWarmed: 5,
});
});
@@ -565,10 +569,13 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
});
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true });
// Both locale families, refill-only: a skipped comprehensive ran no
// eviction, so cards already generated today short-circuit cheaply.
expect(g).toHaveBeenCalledTimes(2);
expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false });
expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: false });
}
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]);
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false);
expect(result).toMatchObject({
comprehensive: "skipped",
assessmentsWarmed: 0,
@@ -597,12 +604,12 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
});
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
expect(g).toHaveBeenCalledTimes(2);
}
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]);
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false);
expect(result).toMatchObject({
comprehensive: "failed",
assessmentsWarmed: 7,
assessmentsWarmed: 14,
metricAssessmentsWarmed: 4,
});
});
@@ -641,12 +648,16 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
const result = await promise;
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
expect(g).toHaveBeenCalledTimes(2);
}
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]);
expect(warmGenericMetrics).toHaveBeenCalledWith(
"u1",
["de", "en"],
false,
);
expect(result).toMatchObject({
comprehensive: "timeout",
assessmentsWarmed: 7,
assessmentsWarmed: 14,
metricAssessmentsWarmed: 3,
});
} finally {
@@ -704,14 +715,14 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
// Comprehensive belongs to the `briefing` surface — skipped here.
expect(generate).not.toHaveBeenCalled();
// But the per-status + generic passes warm.
// But the per-status + generic passes warm, both locale families.
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
expect(g).toHaveBeenCalledTimes(2);
}
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]);
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"], false);
expect(result).toMatchObject({
comprehensive: "skipped",
assessmentsWarmed: 7,
assessmentsWarmed: 14,
metricAssessmentsWarmed: 2,
});
});
@@ -757,9 +768,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
expect(result.comprehensive).toBe("timeout");
// The warm passes ran and are not undone by the abandoned comprehensive.
for (const g of statusGenerators) {
expect(g).toHaveBeenCalledTimes(1);
expect(g).toHaveBeenCalledTimes(2);
}
expect(result.assessmentsWarmed).toBe(7);
expect(result.assessmentsWarmed).toBe(14);
expect(result.metricAssessmentsWarmed).toBe(3);
} finally {
vi.useRealTimers();
+51 -11
View File
@@ -37,7 +37,8 @@
*
* The discovery query selects only users that are worth a generation:
* coach surface enabled (`disableCoach = false`) AND a stale-or-missing
* cache (`insightsCachedAt IS NULL OR < now - 20h`). Whether the user
* cache (`insightsCachedAt IS NULL OR < now - PREGENERATE_STALE_MS`,
* see the constant for why that window is one hour). Whether the user
* has a configured provider is confirmed inside
* `generateComprehensiveInsight` (returns `skipped: no-provider`), so a
* provider-less account costs one cheap chain-resolve and no LLM call.
@@ -88,10 +89,21 @@ export type { InsightPregeneratePayload };
*/
export const INSIGHT_PREGENERATE_CRON = "30 4 * * *";
/** Stale-cache threshold — a hair under the 24 h advisor TTL so the
* overnight run always refreshes a cache that will expire before the
* user's next likely visit. */
export const PREGENERATE_STALE_MS = 20 * 60 * 60 * 1000;
/** Stale-cache threshold for the nightly discovery pass.
*
* The cron runs every 24 h and the advisor cache lives 24 h — so ANY
* cache not written within the last hour will expire before the next
* nightly tick, i.e. at some point during the user's day. The previous
* 20 h threshold structurally excluded every evening visitor: a visit
* at ~19:50 stamps `insightsCachedAt`, the 04:30 run sees a ~9 h-old
* cache and skips, and the cache then expires at ~19:50 the next day —
* exactly when the user returns. That recreated the on-visit warm storm
* (comprehensive feature extraction + the full per-card warm pass in
* the request-serving process) every single evening, which is what the
* nightly pass exists to prevent. One hour keeps the only sensible
* skip: a cache the on-demand path generated minutes before the tick.
*/
export const PREGENERATE_STALE_MS = 60 * 60 * 1000;
/** Per-run cap on the number of users a single tick generates for. */
export const PREGENERATE_BATCH_CAP = 200;
@@ -566,8 +578,11 @@ export async function runInsightPregenerate(
* it just rewrites the same cache row; concurrent enqueues collapse via
* the queue `singletonKey`.
*
* Unlike the nightly cron (de+en), the forced path warms only the caller's
* active `locale` so a single-locale user pays half the provider cost.
* Like the nightly cron, the forced path warms BOTH locales (de+en): its
* own comprehensive step evicts every per-status row across both locale
* families, and the refill-only mode keeps the second family's cost at
* exactly the cards that are cold. The caller's `locale` still picks the
* language of the comprehensive briefing itself.
*
* The three sections are decoupled: the comprehensive step runs under its
* own bounded budget (`COMPREHENSIVE_WARM_TIMEOUT_MS`) and its
@@ -595,6 +610,7 @@ export async function forceWarmUser(
warmGenericMetrics?: (
userId: string,
locales: ReadonlyArray<"de" | "en">,
force?: boolean,
) => Promise<number>;
} = {},
): Promise<ForceWarmResult> {
@@ -603,8 +619,8 @@ export async function forceWarmUser(
options.statusGenerators ?? DEFAULT_STATUS_GENERATORS;
const warmGenericMetrics =
options.warmGenericMetrics ??
((id: string, locales: ReadonlyArray<"de" | "en">) =>
warmGenericMetricCaches(prisma, id, locales));
((id: string, locales: ReadonlyArray<"de" | "en">, force?: boolean) =>
warmGenericMetricCaches(prisma, id, locales, force));
const result: ForceWarmResult = {
comprehensive: "skipped",
@@ -624,7 +640,17 @@ export async function forceWarmUser(
const flags = await getAssistantFlags();
if (!flags.briefing && !flags.insightStatus) return result;
const locales: ReadonlyArray<"de" | "en"> = [locale];
// Warm BOTH supported locales, matching the nightly pass. A fresh
// comprehensive generation evicts every per-status cache row in both
// locale families (`evictPerStatusInsightCache` deletes by scope, not
// by locale) — refilling only the caller's active locale left the
// other family cold, so an account read through two surfaces (German
// web UI + an English Accept-Language client) regenerated the second
// family card-by-card on its next visits, all day, every day. The
// refill mode below short-circuits to the cache for any card already
// generated today, so the second locale costs only the cards that are
// actually cold.
const locales: ReadonlyArray<"de" | "en"> = ["de", "en"];
// The comprehensive step gets its own bounded budget and its failure is
// non-fatal. A slow or stalled briefing generation must not short-circuit
@@ -665,12 +691,26 @@ export async function forceWarmUser(
// outcome that has nothing to warm is a missing provider, and the
// generators detect that themselves at near-zero cost.
if (flags.insightStatus) {
// Mirror the nightly loop's force/refill split: only a `generated` /
// `cached` comprehensive ran `evictPerStatusInsightCache`, so only
// then must every card re-generate. On a failed / timed-out /
// skipped comprehensive the rows survived — refill-only (`force:
// false`) lets a card already generated today short-circuit to its
// cache, which is what keeps the second locale family cheap.
const evicted =
result.comprehensive === "generated" ||
result.comprehensive === "cached";
result.assessmentsWarmed = await warmPerStatusCaches(
userId,
locales,
statusGenerators,
evicted,
);
result.metricAssessmentsWarmed = await warmGenericMetrics(
userId,
locales,
evicted,
);
result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales);
}
annotate({
+10 -8
View File
@@ -13,15 +13,17 @@
* handlers):
*
* - 02:xx status crons → users WITHOUT a comprehensive-pregenerate
* claim: no configured provider, or a comprehensive cache fresh
* enough (< 20 h) that the 04:30 pass will skip them — their
* per-status caches still age out daily and only these crons
* re-fill them.
* claim: no configured provider, or a comprehensive cache fresher
* than `PREGENERATE_STALE_MS` so the 04:30 pass will skip them —
* their per-status caches still age out daily and only these crons
* re-fill them. With the one-hour threshold this cohort is, in
* practice, the provider-less accounts.
* - 04:30 insight-pregenerate → the rest: coach-enabled users with a
* configured provider and a stale (>= 20 h) comprehensive cache.
* That pass regenerates the comprehensive insight AND force-warms
* every per-status cache, so a 02:xx generation for those users
* would be evicted and redone two hours later.
* configured provider and a comprehensive cache older than
* `PREGENERATE_STALE_MS`. That pass regenerates the comprehensive
* insight AND force-warms every per-status cache, so a 02:xx
* generation for those users would be evicted and redone two hours
* later.
*
* Gates, in order:
* 1. Operator kill-switch via `getAssistantFlags()` — the same source
+45
View File
@@ -0,0 +1,45 @@
import type { QueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.16.7 — intent-based data prefetch for the medications page.
*
* The nav links already prefetch the route's JS chunk (next/link
* default in production); what still serialised the first visit was
* the data hop — the list query only fired once the page chunk had
* mounted. Wiring this to the nav link's hover / touch / focus intent
* puts `/api/medications` (the response carries the per-medication
* `nextDueAt` due times) in flight while the navigation commits.
*
* The 15 s prefetch window only bounds how old a cache entry may be
* before an intent re-fires the request — the mounted page query rides
* the provider-default `staleTime` (5 min) and consumes whatever this
* prefetch parked. The server caches the list 60 s per user, so even a
* missed window stays cheap.
*/
export const MEDICATIONS_LIST_STALE_TIME_MS = 15_000;
export function prefetchMedicationsList(queryClient: QueryClient): void {
void queryClient.prefetchQuery({
queryKey: queryKeys.medications(),
queryFn: () => apiGet("/api/medications"),
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
});
}
/**
* Intent props for a nav link pointing at `/medications` — fires the
* list prefetch on hover / touch / keyboard focus, i.e. before the
* router even commits. `prefetchQuery` dedupes internally (in-flight
* promise reuse + the stale window above), so the three handlers and a
* subsequent route-commit prefetch collapse into at most one request.
*/
export function medicationsPrefetchIntentProps(queryClient: QueryClient): {
onPointerEnter: () => void;
onTouchStart: () => void;
onFocus: () => void;
} {
const fire = () => prefetchMedicationsList(queryClient);
return { onPointerEnter: fire, onTouchStart: fire, onFocus: fire };
}