mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user