perf(insights): compute-once feature snapshot per warm scope

The comprehensive briefing's bounded feature read (extractFeatures) was
paid more than once where it should not be: the on-demand route and the
shared generator each run a downgrade ladder that re-reads with identical
arguments, and a nightly warm has no shared object across consumers in a
single tick.

Add a request/run-scoped feature cache: withFeatureCacheScope opens an
AsyncLocalStorage store, getCachedFeatures memoises the first compute per
(userId, day, includeRaw, sinceDays), gated on a cheap salient-input
fingerprint so a mid-scope ingest recomputes and a probe failure fails
safe. Outside a scope it is a pure passthrough. Wired into the
comprehensive generator, the on-demand POST handler, and the nightly
per-candidate loop + forceWarmUser. Coach keeps its own snapshot.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 10:23:14 +02:00
1 parent 7f9aeba918
commit d7c71f3b32
5 files changed
+1257 -784

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,235 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { groupBy: vi.fn() },
},
}));
// Berlin day key is deterministic input to the cache key; pin it.
vi.mock("@/lib/tz/resolver", () => ({
toBerlinDayKey: () => "2026-06-21",
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { prisma } from "@/lib/db";
import {
getCachedFeatures,
withFeatureCacheScope,
} from "../feature-cache";
const T0 = new Date("2026-06-20T08:00:00.000Z");
function fingerprintRows(weightCount: number) {
return [
{ type: "WEIGHT", _count: { _all: weightCount }, _max: { measuredAt: T0 } },
];
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.groupBy).mockResolvedValue(
fingerprintRows(10) as never,
);
});
describe("getCachedFeatures (P3)", () => {
it("computes once and reuses within a scope for identical key + inputs", async () => {
const compute = vi.fn().mockResolvedValue({ marker: "features-v1" });
const { first, second } = await withFeatureCacheScope(async () => {
const first = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
const second = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
return { first, second };
});
expect(compute).toHaveBeenCalledTimes(1);
// The same object reference is handed back — a true reuse, not a re-run.
expect(second).toBe(first);
expect(first).toEqual({ marker: "features-v1" });
});
it("recomputes when the salient input fingerprint changes mid-scope", async () => {
const compute = vi
.fn()
.mockResolvedValueOnce({ marker: "before" })
.mockResolvedValueOnce({ marker: "after" });
const out = await withFeatureCacheScope(async () => {
const before = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
// A new reading lands mid-scope — the fingerprint probe now sees a
// higher count, so the gate misses and the read is recomputed.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue(
fingerprintRows(11) as never,
);
const after = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
return { before, after };
});
expect(compute).toHaveBeenCalledTimes(2);
expect(out.before).toEqual({ marker: "before" });
expect(out.after).toEqual({ marker: "after" });
});
it("keys separately on includeRaw and sinceDays", async () => {
const compute = vi
.fn()
.mockResolvedValueOnce({ k: "agg-400" })
.mockResolvedValueOnce({ k: "raw-400" })
.mockResolvedValueOnce({ k: "agg-90" });
await withFeatureCacheScope(async () => {
await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
await getCachedFeatures({
userId: "u1",
includeRaw: true,
sinceDays: 400,
compute,
});
await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 90,
compute,
});
});
// Three distinct keys → three computes (no cross-key collision).
expect(compute).toHaveBeenCalledTimes(3);
});
it("keys separately per user (no cross-user leak)", async () => {
const compute = vi
.fn()
.mockResolvedValueOnce({ u: "u1" })
.mockResolvedValueOnce({ u: "u2" });
const out = await withFeatureCacheScope(async () => {
const a = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
const b = await getCachedFeatures({
userId: "u2",
includeRaw: false,
sinceDays: 400,
compute,
});
return { a, b };
});
expect(compute).toHaveBeenCalledTimes(2);
expect(out.a).toEqual({ u: "u1" });
expect(out.b).toEqual({ u: "u2" });
});
it("passes straight through (no caching, no probe) outside any scope", async () => {
const compute = vi.fn().mockResolvedValue({ marker: "x" });
const a = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
const b = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
// No scope → no memoisation → each call computes; and the fingerprint
// probe is never even issued (it only runs inside a scope).
expect(compute).toHaveBeenCalledTimes(2);
expect(prisma.measurement.groupBy).not.toHaveBeenCalled();
expect(a).toEqual({ marker: "x" });
expect(b).toEqual({ marker: "x" });
});
it("does not share a cache across sibling scopes", async () => {
const compute = vi.fn().mockResolvedValue({ marker: "y" });
await withFeatureCacheScope(() =>
getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
}),
);
await withFeatureCacheScope(() =>
getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
}),
);
// Each scope has its own store, so the second scope recomputes.
expect(compute).toHaveBeenCalledTimes(2);
});
it("recomputes on a fingerprint probe failure rather than serving a stale object", async () => {
const compute = vi
.fn()
.mockResolvedValueOnce({ marker: "1" })
.mockResolvedValueOnce({ marker: "2" });
vi.mocked(prisma.measurement.groupBy).mockRejectedValue(
new Error("db down"),
);
const out = await withFeatureCacheScope(async () => {
const first = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
const second = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
return { first, second };
});
// A failed probe yields a unique sentinel hash each call → the key never
// matches → the read is recomputed (fail-safe, never a wrong reuse).
expect(compute).toHaveBeenCalledTimes(2);
expect(out.first).toEqual({ marker: "1" });
expect(out.second).toEqual({ marker: "2" });
});
});
+22 -6
View File
@@ -31,6 +31,7 @@ import {
buildBriefingGroundingCorrection,
} from "@/lib/ai/briefing-grounding";
import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter";
import { getCachedFeatures } from "@/lib/insights/feature-cache";
import {
buildBriefingIllnessCycleContext,
buildBriefingIllnessCyclePrompt,
@@ -734,19 +735,34 @@ export async function generateComprehensiveInsight(
const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS };
let features: Awaited<ReturnType<typeof extractFeatures>>;
try {
features = await extractFeatures(userId, includeRaw, featureWindow);
// v1.18.11 P3 — compute-once-per-scope. Inside the nightly-tick scope (or
// the on-demand route scope) the bounded feature read is shared, so the
// downgrade-ladder re-reads below and any sibling consumer in the same
// scope reuse this object instead of re-querying.
features = await getCachedFeatures({
userId,
includeRaw,
sinceDays: featureWindow.sinceDays,
compute: () => extractFeatures(userId, includeRaw, featureWindow),
});
} catch (err) {
if (err instanceof FeaturesPayloadTooLargeError) {
try {
features = await extractFeatures(userId, false, featureWindow);
features = await getCachedFeatures({
userId,
includeRaw: false,
sinceDays: featureWindow.sinceDays,
compute: () => extractFeatures(userId, false, featureWindow),
});
} catch (retryErr) {
if (retryErr instanceof FeaturesPayloadTooLargeError) {
try {
const aggregated = await extractFeatures(
const aggregated = await getCachedFeatures({
userId,
false,
featureWindow,
);
includeRaw: false,
sinceDays: featureWindow.sinceDays,
compute: () => extractFeatures(userId, false, featureWindow),
});
features = applyInsightsExcludeFilter(
aggregated,
MAX_DOWNGRADE_TOKENS,
+177
View File
@@ -0,0 +1,177 @@
/**
* v1.18.11 (P3) — request/run-scoped shared feature cache.
*
* The comprehensive briefing builds its feature snapshot via
* `extractFeatures(userId, includeRaw, { sinceDays })`. That call materialises a
* bounded measurement read + the per-type rollup folds + the all-time-extremes
* aggregation — the most expensive read in the insight pipeline. Two patterns
* paid for it more than once:
*
* 1. The on-demand route + `comprehensive-generate.ts` each run a downgrade
* ladder that, on an oversize payload, re-invokes
* `extractFeatures(userId, false, window)` with byte-identical arguments —
* a second (and third) full read for the same user in the same request.
* 2. A single nightly tick warms one user's briefing and may, in the same
* run-scope, touch the same feature read again (e.g. a follow-on reduce
* stage). Without a shared object each consumer re-queried the DB.
*
* This module turns `extractFeatures` into a compute-ONCE-per-scope read: a
* caller opens a scope with `withFeatureCacheScope(...)`, and every
* `getCachedFeatures(...)` inside that scope keyed on the SAME
* `(userId, calendar-day, includeRaw, sinceDays, inputHash)` reuses the first
* computed object. This is an INPUT gate (compute-once + reuse), not a
* post-build skip: the key folds a cheap input fingerprint so an unchanged
* input set is computed once and a genuinely changed one recomputes.
*
* Scope discipline:
* - The cache lives only for the duration of the `withFeatureCacheScope`
* callback (an `AsyncLocalStorage` store). Outside any scope
* `getCachedFeatures` falls straight through to the computer — no global
* state, no cross-user leak, no stale-across-requests hazard.
* - The key is `(userId, day, includeRaw, sinceDays, inputHash)`. `day` is
* the Berlin calendar day so a scope that straddles midnight recomputes
* rather than serving yesterday's window. `inputHash` is a cheap
* count/newest fingerprint of the salient measurement + mood inputs, so a
* mid-scope ingest (rare, but possible on a long-running warm) recomputes
* instead of reusing a pre-ingest snapshot.
*
* The Coach owns its own snapshot builder and is deliberately NOT routed
* through here (its snapshot layers many blocks beyond `extractFeatures`); this
* cache is exposed as a standalone helper either could adopt without coupling.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
/** One memoised feature read, with the input fingerprint it was keyed on. */
interface CacheEntry {
/** The resolved feature object (typed loosely — the caller re-narrows). */
value: unknown;
/** The salient-input fingerprint the value was computed for. */
inputHash: string;
}
/** The per-scope store: a map from the stable key prefix to its entry. */
type FeatureCacheStore = Map<string, CacheEntry>;
const store = new AsyncLocalStorage<FeatureCacheStore>();
/**
* The measurement types whose count + newest stamp fingerprint a briefing
* feature read. This is the same salient set `extractFeatures` narrates over;
* the fingerprint is intentionally coarse (count + newest per type) so it is a
* single cheap grouped query, not a second full read. A new or removed reading
* for any of these flips the hash and forces a recompute within the scope.
*/
const FEATURE_INPUT_TYPES: readonly MeasurementType[] = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
"TIME_IN_DAYLIGHT",
];
/**
* Run `fn` inside a fresh feature-cache scope. Every `getCachedFeatures` call
* made (synchronously or via awaited async work) within `fn` shares one cache.
* Nesting reuses the innermost store, so an inner scope never shadows an outer
* one's already-computed reads.
*/
export function withFeatureCacheScope<T>(fn: () => Promise<T>): Promise<T> {
const existing = store.getStore();
if (existing) return fn();
return store.run(new Map(), fn);
}
/**
* Cheap salient-input fingerprint for `userId` — one grouped query over the
* feature input types' count + newest stamp. Deterministic across group order
* (sorted before hashing). Best-effort: a read failure yields a sentinel hash
* so the cache degrades to "always recompute" rather than reusing a wrong
* object.
*/
async function computeFeatureInputHash(userId: string): Promise<string> {
try {
const grouped = await prisma.measurement.groupBy({
by: ["type"],
where: {
userId,
type: { in: [...FEATURE_INPUT_TYPES] },
deletedAt: null,
},
_count: { _all: true },
_max: { measuredAt: true },
});
const fingerprint = grouped
.map((row) => ({
type: row.type,
count: row._count._all,
newest: row._max.measuredAt ? row._max.measuredAt.toISOString() : null,
}))
.sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0));
return hashInsightSnapshot({ featureInput: fingerprint });
} catch {
// A probe failure must never serve a stale object: a unique sentinel makes
// the key miss so the caller computes fresh.
return `nofingerprint:${Date.now()}:${Math.random()}`;
}
}
/**
* Compute-once-per-scope wrapper around a feature read.
*
* Inside a `withFeatureCacheScope`, the first call for a given
* `(userId, day, includeRaw, sinceDays, inputHash)` runs `compute()` and stores
* the result; subsequent calls with the same key reuse it. Outside any scope it
* simply runs `compute()` (no caching). The returned value is whatever
* `compute()` resolves to — callers re-narrow it to their feature type.
*
* The input fingerprint is probed once per call (a cheap grouped query); the
* cost is paid to GUARANTEE correctness (a mid-scope ingest recomputes). The
* net saving is the full feature read on every cache HIT, which dwarfs the
* fingerprint probe.
*/
export async function getCachedFeatures<T>(args: {
userId: string;
includeRaw: boolean;
sinceDays: number;
compute: () => Promise<T>;
}): Promise<T> {
const current = store.getStore();
if (!current) {
// No active scope — pass straight through. This keeps the helper a no-op
// for callers that never opened a scope (e.g. unit paths) so behaviour is
// identical to a bare `extractFeatures`.
return args.compute();
}
const inputHash = await computeFeatureInputHash(args.userId);
const dayKey = toBerlinDayKey(new Date());
const key = `${args.userId}|${dayKey}|${args.includeRaw ? "raw" : "agg"}|${args.sinceDays}`;
const hit = current.get(key);
if (hit && hit.inputHash === inputHash) {
annotate({
action: { name: "insights.feature_cache.hit" },
meta: { since_days: args.sinceDays, include_raw: args.includeRaw },
});
return hit.value as T;
}
const value = await args.compute();
current.set(key, { value, inputHash });
annotate({
action: { name: "insights.feature_cache.miss" },
meta: { since_days: args.sinceDays, include_raw: args.includeRaw },
});
return value;
}
+275 -260
View File
@@ -59,6 +59,7 @@ import {
type GenerateOutcome,
} from "@/lib/insights/comprehensive-generate";
import { generateStatusBatchForUser } from "@/lib/insights/status-batch";
import { withFeatureCacheScope } from "@/lib/insights/feature-cache";
import { generateMetricStatus } from "@/lib/insights/metric-status";
import {
METRIC_STATUS_IDS,
@@ -486,100 +487,106 @@ export async function runInsightPregenerate(
result.total = candidates.length;
for (const candidate of candidates) {
// Budget gate — one COMPREHENSIVE pre-generation per user per 20 h.
// The route's on-demand path uses a different bucket
// (`insights:${userId}`), so this never starves a user's manual
// regenerate quota. The gate bounds the comprehensive cost only: a
// blocked user still gets the refill-only status warm below, because
// the 02:xx status crons already skipped every pregenerate candidate
// on the assumption that THIS pass warms their cards — exiting early
// here left those cards cold until the first on-visit generation.
const budget = await checkRateLimit(
`insight-pregenerate:${candidate.id}`,
1,
PREGENERATE_BUDGET_WINDOW_MS,
);
const locale = normalizeLocale(candidate.locale);
let outcome: GenerateOutcome | null = null;
if (!budget.allowed) {
result.budgetBlocked++;
} else {
// Force a fresh generation: the discovery window (20 h) is shorter
// than the generator's 24 h cache TTL, so without `force` a user
// whose cache is 20–24 h old would be discovered, consume the
// budget bucket, then short-circuit to `cached` with no actual
// pre-generation — defeating the "warm the cache before the user's
// morning visit" intent for the common case. The per-user budget
// bucket (above) bounds attempts, and the generator's content-hash
// gate (v1.16.8) turns a same-data force into a timestamp refresh
// (`unchanged`) with no provider call.
//
// Bounded budget + abort (v1.16.1): one stalled provider must not
// pin the whole batch behind a single candidate, and `withTimeout`
// alone cannot cancel the detached generation — without the abort a
// late resolve would still write a cache row + timestamp after the
// loop moved on (the same race the forced single-user warm closes).
const controller = new AbortController();
const bounded = await withTimeout(
() =>
generate(candidate.id, {
locale,
force: true,
signal: controller.signal,
}),
COMPREHENSIVE_WARM_TIMEOUT_MS,
null,
() => controller.abort(),
// v1.18.11 P3 — one feature-cache scope per candidate, so the
// comprehensive briefing's bounded feature read is computed once and any
// sibling consumer in this candidate's warm reuses it. Scoped per user so
// no cross-user object can leak.
await withFeatureCacheScope(async () => {
// Budget gate — one COMPREHENSIVE pre-generation per user per 20 h.
// The route's on-demand path uses a different bucket
// (`insights:${userId}`), so this never starves a user's manual
// regenerate quota. The gate bounds the comprehensive cost only: a
// blocked user still gets the refill-only status warm below, because
// the 02:xx status crons already skipped every pregenerate candidate
// on the assumption that THIS pass warms their cards — exiting early
// here left those cards cold until the first on-visit generation.
const budget = await checkRateLimit(
`insight-pregenerate:${candidate.id}`,
1,
PREGENERATE_BUDGET_WINDOW_MS,
);
if (bounded.timedOut || bounded.errored || bounded.value === null) {
result.failed++;
const locale = normalizeLocale(candidate.locale);
let outcome: GenerateOutcome | null = null;
if (!budget.allowed) {
result.budgetBlocked++;
} else {
outcome = bounded.value;
switch (outcome.status) {
case "generated":
result.generated++;
break;
case "cached":
result.cached++;
break;
case "unchanged":
result.unchanged++;
break;
case "skipped":
result.skipped++;
break;
case "failed":
result.failed++;
break;
// Force a fresh generation: the discovery window (20 h) is shorter
// than the generator's 24 h cache TTL, so without `force` a user
// whose cache is 20–24 h old would be discovered, consume the
// budget bucket, then short-circuit to `cached` with no actual
// pre-generation — defeating the "warm the cache before the user's
// morning visit" intent for the common case. The per-user budget
// bucket (above) bounds attempts, and the generator's content-hash
// gate (v1.16.8) turns a same-data force into a timestamp refresh
// (`unchanged`) with no provider call.
//
// Bounded budget + abort (v1.16.1): one stalled provider must not
// pin the whole batch behind a single candidate, and `withTimeout`
// alone cannot cancel the detached generation — without the abort a
// late resolve would still write a cache row + timestamp after the
// loop moved on (the same race the forced single-user warm closes).
const controller = new AbortController();
const bounded = await withTimeout(
() =>
generate(candidate.id, {
locale,
force: true,
signal: controller.signal,
}),
COMPREHENSIVE_WARM_TIMEOUT_MS,
null,
() => controller.abort(),
);
if (bounded.timedOut || bounded.errored || bounded.value === null) {
result.failed++;
} else {
outcome = bounded.value;
switch (outcome.status) {
case "generated":
result.generated++;
break;
case "cached":
result.cached++;
break;
case "unchanged":
result.unchanged++;
break;
case "skipped":
result.skipped++;
break;
case "failed":
result.failed++;
break;
}
}
}
}
// Warm the per-metric assessment caches — refill-only, single locale
// (v1.16.8). The comprehensive write no longer evicts the per-status
// rows, so there is nothing to force-refill: a card already generated
// today is a cheap cache read, a cold card runs its generator, and the
// generator's content-hash gate turns a same-data regeneration into a
// timestamp refresh with no provider call. The warm runs on every
// comprehensive outcome (budget-blocked, failed, timed-out,
// consent-skipped included) because the 02:xx status crons skip every
// pregenerate candidate on the assumption that THIS pass covers them.
//
// The only outcome with nothing to warm is a missing provider
// (`skipped`/`no-provider`): the per-card generators would no-op to
// their fallbacks anyway, so the pass is skipped to save the
// chain-resolves.
if (outcome?.status === "skipped" && outcome.reason === "no-provider") {
continue;
}
result.assessmentsWarmed += await warmStatus(candidate.id, [locale]);
// v1.8.7.1 — warm the generic per-HealthKit-metric caches too, for
// the user's data-bearing metrics only (the helper filters via one
// grouped count, so an empty metric never reaches the provider).
result.metricAssessmentsWarmed += await warmGenericMetrics(candidate.id, [
locale,
]);
// Warm the per-metric assessment caches — refill-only, single locale
// (v1.16.8). The comprehensive write no longer evicts the per-status
// rows, so there is nothing to force-refill: a card already generated
// today is a cheap cache read, a cold card runs its generator, and the
// generator's content-hash gate turns a same-data regeneration into a
// timestamp refresh with no provider call. The warm runs on every
// comprehensive outcome (budget-blocked, failed, timed-out,
// consent-skipped included) because the 02:xx status crons skip every
// pregenerate candidate on the assumption that THIS pass covers them.
//
// The only outcome with nothing to warm is a missing provider
// (`skipped`/`no-provider`): the per-card generators would no-op to
// their fallbacks anyway, so the pass is skipped to save the
// chain-resolves.
if (outcome?.status === "skipped" && outcome.reason === "no-provider") {
return;
}
result.assessmentsWarmed += await warmStatus(candidate.id, [locale]);
// v1.8.7.1 — warm the generic per-HealthKit-metric caches too, for
// the user's data-bearing metrics only (the helper filters via one
// grouped count, so an empty metric never reaches the provider).
result.metricAssessmentsWarmed += await warmGenericMetrics(candidate.id, [
locale,
]);
});
}
// Wide-event tally so the nightly dashboard can track how many
@@ -661,194 +668,202 @@ export async function forceWarmUser(
now?: Date;
} = {},
): Promise<ForceWarmResult> {
const generate = options.generate ?? generateComprehensiveInsight;
// v1.18.7 (HIGH-1) — batch the seven status warms into ONE provider call
// in production; keep the per-card path when a test injects generators.
const injectedStatusGenerators = options.statusGenerators;
const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) =>
injectedStatusGenerators
? warmPerStatusCaches(userId, locales, injectedStatusGenerators)
: warmStatusBatch(userId, locales);
const warmGenericMetrics =
options.warmGenericMetrics ??
((id: string, locales: ReadonlyArray<"de" | "en">) =>
warmGenericMetricCaches(prisma, id, locales));
const now = options.now ?? new Date();
// v1.18.11 P3 — one feature-cache scope for the whole forced warm of this
// user, so the comprehensive briefing's bounded feature read is computed
// once and reused by any sibling consumer in the same warm.
return withFeatureCacheScope(async () => {
const generate = options.generate ?? generateComprehensiveInsight;
// v1.18.7 (HIGH-1) — batch the seven status warms into ONE provider call
// in production; keep the per-card path when a test injects generators.
const injectedStatusGenerators = options.statusGenerators;
const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) =>
injectedStatusGenerators
? warmPerStatusCaches(userId, locales, injectedStatusGenerators)
: warmStatusBatch(userId, locales);
const warmGenericMetrics =
options.warmGenericMetrics ??
((id: string, locales: ReadonlyArray<"de" | "en">) =>
warmGenericMetricCaches(prisma, id, locales));
const now = options.now ?? new Date();
const result: ForceWarmResult = {
comprehensive: "skipped",
assessmentsWarmed: 0,
metricAssessmentsWarmed: 0,
};
const result: ForceWarmResult = {
comprehensive: "skipped",
assessmentsWarmed: 0,
metricAssessmentsWarmed: 0,
};
// v1.9.0 — gate each section on the flag for the surface it actually
// warms, matching the route. The HTTP route admits this job on the per-user
// `insightStatus` surface, so warming the per-status + generic-metric cards
// must run whenever `insightStatus` is enabled. Only the comprehensive
// briefing belongs to the `briefing` surface — gating the whole warm on
// `briefing` (the previous behaviour) let an operator's global `briefing`
// kill-switch silently suppress the assessment cards the user has enabled.
// When the master assistant switch is off both flags resolve false, so the
// whole thing still no-ops.
const flags = await getAssistantFlags();
if (!flags.briefing && !flags.insightStatus) return result;
// v1.9.0 — gate each section on the flag for the surface it actually
// warms, matching the route. The HTTP route admits this job on the per-user
// `insightStatus` surface, so warming the per-status + generic-metric cards
// must run whenever `insightStatus` is enabled. Only the comprehensive
// briefing belongs to the `briefing` surface — gating the whole warm on
// `briefing` (the previous behaviour) let an operator's global `briefing`
// kill-switch silently suppress the assessment cards the user has enabled.
// When the master assistant switch is off both flags resolve false, so the
// whole thing still no-ops.
const flags = await getAssistantFlags();
if (!flags.briefing && !flags.insightStatus) return result;
// v1.16.8 — warm only the caller's resolved locale. The old dual-locale
// warm compensated for the comprehensive write's cross-locale per-status
// eviction; that eviction is gone, so the other locale family's rows
// survive untouched and a client actually reading the second locale
// warms it lazily through the read-path enqueue on its first miss.
const locales: ReadonlyArray<"de" | "en"> = [locale];
// v1.16.8 — warm only the caller's resolved locale. The old dual-locale
// warm compensated for the comprehensive write's cross-locale per-status
// eviction; that eviction is gone, so the other locale family's rows
// survive untouched and a client actually reading the second locale
// warms it lazily through the read-path enqueue on its first miss.
const locales: ReadonlyArray<"de" | "en"> = [locale];
// The comprehensive step gets its own bounded budget and its failure is
// non-fatal. A slow or stalled briefing generation must not short-circuit
// the per-status + generic-metric passes — those are the ~37 cards the
// user actually clicks first, and warming them is independent of the
// comprehensive insight (each generator resolves its own provider chain
// and writes its own cache row). A timeout / error / skipped comprehensive
// falls through to the warm passes instead of aborting the whole job.
//
// On a timeout `withTimeout` cannot cancel the detached generation; without
// a cut-off a late-resolving comprehensive would still write its cache row
// + timestamp after the job moved on. Thread an AbortController so the
// timeout aborts the generation before it can touch the cache.
//
// v1.16.8 — three pre-flight gates make the forced path idempotent and
// bounded (the enqueue-side singleton alone cannot, because the client's
// revalidation poll outlives any reasonable singleton window):
// 1. freshness — a comprehensive warmed within the last hour is
// current; re-forcing it would at best re-discover an unchanged
// hash and at worst burn a generation on intra-day noise.
// 2. failure backoff — a forced attempt that failed within the last
// hour stays failed for most causes (broken key, dead local
// endpoint); re-attempting on every page-open re-runs the full
// feature extraction + provider stall each time.
// 3. daily cap — at most FORCE_WARM_DAILY_LIMIT forced attempts per
// rolling 24 h, so even a client bug that defeats 1+2 cannot turn
// the forced path into an unmetered generation loop.
if (flags.briefing) {
let freshness: {
insightsCachedAt: Date | null;
insightsWarmFailedAt: Date | null;
} | null = null;
try {
freshness = await prisma.user.findUnique({
where: { id: userId },
select: { insightsCachedAt: true, insightsWarmFailedAt: true },
});
} catch {
// Best-effort pre-flight — on a read failure fall through to the
// budgeted generation attempt below.
}
// The comprehensive step gets its own bounded budget and its failure is
// non-fatal. A slow or stalled briefing generation must not short-circuit
// the per-status + generic-metric passes — those are the ~37 cards the
// user actually clicks first, and warming them is independent of the
// comprehensive insight (each generator resolves its own provider chain
// and writes its own cache row). A timeout / error / skipped comprehensive
// falls through to the warm passes instead of aborting the whole job.
//
// On a timeout `withTimeout` cannot cancel the detached generation; without
// a cut-off a late-resolving comprehensive would still write its cache row
// + timestamp after the job moved on. Thread an AbortController so the
// timeout aborts the generation before it can touch the cache.
//
// v1.16.8 — three pre-flight gates make the forced path idempotent and
// bounded (the enqueue-side singleton alone cannot, because the client's
// revalidation poll outlives any reasonable singleton window):
// 1. freshness — a comprehensive warmed within the last hour is
// current; re-forcing it would at best re-discover an unchanged
// hash and at worst burn a generation on intra-day noise.
// 2. failure backoff — a forced attempt that failed within the last
// hour stays failed for most causes (broken key, dead local
// endpoint); re-attempting on every page-open re-runs the full
// feature extraction + provider stall each time.
// 3. daily cap — at most FORCE_WARM_DAILY_LIMIT forced attempts per
// rolling 24 h, so even a client bug that defeats 1+2 cannot turn
// the forced path into an unmetered generation loop.
if (flags.briefing) {
let freshness: {
insightsCachedAt: Date | null;
insightsWarmFailedAt: Date | null;
} | null = null;
try {
freshness = await prisma.user.findUnique({
where: { id: userId },
select: { insightsCachedAt: true, insightsWarmFailedAt: true },
});
} catch {
// Best-effort pre-flight — on a read failure fall through to the
// budgeted generation attempt below.
}
const cachedAt = freshness?.insightsCachedAt ?? null;
const failedAt = freshness?.insightsWarmFailedAt ?? null;
const isFresh =
cachedAt !== null &&
now.getTime() - cachedAt.getTime() < FORCE_WARM_FRESH_MS;
const inBackoff =
!isFresh &&
failedAt !== null &&
now.getTime() - failedAt.getTime() < FORCE_WARM_FAILURE_BACKOFF_MS;
const cachedAt = freshness?.insightsCachedAt ?? null;
const failedAt = freshness?.insightsWarmFailedAt ?? null;
const isFresh =
cachedAt !== null &&
now.getTime() - cachedAt.getTime() < FORCE_WARM_FRESH_MS;
const inBackoff =
!isFresh &&
failedAt !== null &&
now.getTime() - failedAt.getTime() < FORCE_WARM_FAILURE_BACKOFF_MS;
if (isFresh) {
result.comprehensive = "fresh";
annotate({
action: { name: "insights.pregenerate.force.fresh" },
meta: { cached_at: cachedAt?.toISOString() ?? null },
});
} else if (inBackoff) {
result.comprehensive = "backoff";
annotate({
action: { name: "insights.pregenerate.force.backoff" },
meta: { failed_at: failedAt?.toISOString() ?? null },
});
} else {
const budget = await checkRateLimit(
`insight-pregenerate-daily:${userId}`,
FORCE_WARM_DAILY_LIMIT,
FORCE_WARM_DAILY_WINDOW_MS,
);
if (!budget.allowed) {
result.comprehensive = "capped";
if (isFresh) {
result.comprehensive = "fresh";
annotate({
action: { name: "insights.pregenerate.force.capped" },
meta: { daily_limit: FORCE_WARM_DAILY_LIMIT },
action: { name: "insights.pregenerate.force.fresh" },
meta: { cached_at: cachedAt?.toISOString() ?? null },
});
} else if (inBackoff) {
result.comprehensive = "backoff";
annotate({
action: { name: "insights.pregenerate.force.backoff" },
meta: { failed_at: failedAt?.toISOString() ?? null },
});
} else {
const controller = new AbortController();
const comprehensive = await withTimeout(
() =>
generate(userId, {
locale,
force: true,
signal: controller.signal,
}),
COMPREHENSIVE_WARM_TIMEOUT_MS,
null,
() => controller.abort(),
const budget = await checkRateLimit(
`insight-pregenerate-daily:${userId}`,
FORCE_WARM_DAILY_LIMIT,
FORCE_WARM_DAILY_WINDOW_MS,
);
if (comprehensive.timedOut) {
result.comprehensive = "timeout";
} else if (comprehensive.errored || comprehensive.value === null) {
result.comprehensive = "failed";
if (!budget.allowed) {
result.comprehensive = "capped";
annotate({
action: { name: "insights.pregenerate.force.capped" },
meta: { daily_limit: FORCE_WARM_DAILY_LIMIT },
});
} else {
result.comprehensive = comprehensive.value.status;
}
const controller = new AbortController();
const comprehensive = await withTimeout(
() =>
generate(userId, {
locale,
force: true,
signal: controller.signal,
}),
COMPREHENSIVE_WARM_TIMEOUT_MS,
null,
() => controller.abort(),
);
if (comprehensive.timedOut) {
result.comprehensive = "timeout";
} else if (comprehensive.errored || comprehensive.value === null) {
result.comprehensive = "failed";
} else {
result.comprehensive = comprehensive.value.status;
}
// Stamp / clear the failure marker for the backoff gate above. A
// `skipped` outcome (no provider / no consent) is not a failure —
// it costs nothing and should not delay a user who configures a
// provider a minute later.
const failed =
result.comprehensive === "failed" ||
result.comprehensive === "timeout";
try {
if (failed) {
await prisma.user.update({
where: { id: userId },
data: { insightsWarmFailedAt: now },
});
} else if (failedAt !== null) {
await prisma.user.update({
where: { id: userId },
data: { insightsWarmFailedAt: null },
});
// Stamp / clear the failure marker for the backoff gate above. A
// `skipped` outcome (no provider / no consent) is not a failure —
// it costs nothing and should not delay a user who configures a
// provider a minute later.
const failed =
result.comprehensive === "failed" ||
result.comprehensive === "timeout";
try {
if (failed) {
await prisma.user.update({
where: { id: userId },
data: { insightsWarmFailedAt: now },
});
} else if (failedAt !== null) {
await prisma.user.update({
where: { id: userId },
data: { insightsWarmFailedAt: null },
});
}
} catch {
// Marker maintenance is best-effort; never fail the warm on it.
}
} catch {
// Marker maintenance is best-effort; never fail the warm on it.
}
}
}
}
// Run the per-status + generic-metric warm passes whenever the
// `insightStatus` surface is enabled, regardless of the comprehensive
// outcome. Each generator independently no-ops to its no-key fallback when
// no provider is configured (no LLM call) and serves its fallback without
// persisting on a per-card timeout, so running them after a failed
// comprehensive is safe and bounded by their own per-card budgets. The only
// outcome that has nothing to warm is a missing provider, and the
// generators detect that themselves at near-zero cost.
if (flags.insightStatus) {
// Refill-only (v1.16.8): a card already generated today is a cheap
// cache read; a cold card runs its generator, whose content-hash gate
// skips the LLM when the underlying data did not change. There is no
// force mode left to mirror — the comprehensive write no longer evicts
// the per-status rows.
result.assessmentsWarmed = await warmStatus(userId, locales);
result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales);
}
// Run the per-status + generic-metric warm passes whenever the
// `insightStatus` surface is enabled, regardless of the comprehensive
// outcome. Each generator independently no-ops to its no-key fallback when
// no provider is configured (no LLM call) and serves its fallback without
// persisting on a per-card timeout, so running them after a failed
// comprehensive is safe and bounded by their own per-card budgets. The only
// outcome that has nothing to warm is a missing provider, and the
// generators detect that themselves at near-zero cost.
if (flags.insightStatus) {
// Refill-only (v1.16.8): a card already generated today is a cheap
// cache read; a cold card runs its generator, whose content-hash gate
// skips the LLM when the underlying data did not change. There is no
// force mode left to mirror — the comprehensive write no longer evicts
// the per-status rows.
result.assessmentsWarmed = await warmStatus(userId, locales);
result.metricAssessmentsWarmed = await warmGenericMetrics(
userId,
locales,
);
}
annotate({
action: { name: "insights.pregenerate.force" },
meta: {
locale,
comprehensive: result.comprehensive,
assessments_warmed: result.assessmentsWarmed,
metric_assessments_warmed: result.metricAssessmentsWarmed,
},
annotate({
action: { name: "insights.pregenerate.force" },
meta: {
locale,
comprehensive: result.comprehensive,
assessments_warmed: result.assessmentsWarmed,
metric_assessments_warmed: result.metricAssessmentsWarmed,
},
});
return result;
});
return result;
}