Files
HealthLog/src/lib/insights/feature-cache.ts
T
Marc-André Bombeck dfa4533c7c fix(insights): roll the daily card cache over at the user's own midnight
The eleven per-metric/derived-score status helpers (pulse, weight,
bmi, mood, blood-pressure, medication-compliance, general, the generic
metric card, biomarker, the shared feature cache, the derived-score AI
warm layer) computed todayKey from toBerlinDayKey(new Date()) before
resolving the user's own timezone — several fetched the profile only
for downstream day-bucketing, after the cache read had already run on
the Berlin key. A non-Berlin user's card regenerated at their local
evening (Berlin midnight) and, for biomarker series and the
derived-score prompt label, mis-keyed data or displayed a hardcoded
"(Europe/Berlin)" regardless of their actual zone.

Threads resolveUserTimezone()/userDayKey() ahead of every cache read
so the day-key and the data bucketing agree, and reuses the one
resolved value everywhere downstream instead of re-deriving it.
2026-07-18 06:47:32 +02:00

185 lines
7.6 KiB
TypeScript

/**
* 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 user's own calendar day (v1.30.3 QA F5 — was Berlin-pinned for
* every user) so a scope that straddles the user's local 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 { resolveUserTimezone, userDayKey } 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();
}
// v1.30.3 (QA F5) — the day-key rolls over at the USER'S own midnight, not
// Berlin's, so a Coach/briefing scope spanning the user's local midnight
// recomputes at the right instant for that user.
const [inputHash, userTz] = await Promise.all([
computeFeatureInputHash(args.userId),
resolveUserTimezone(args.userId),
]);
const dayKey = userDayKey(new Date(), userTz);
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;
}