Files
HealthLog/src/lib/cache/server-cache.ts
T
Marc-André BombeckandClaude Opus 4.8 6de5de0284 perf(dashboard): serve the snapshot stale-while-revalidate on a measurement write
The dashboard snapshot lives in the analytics cache bucket, which had no
stale window, and every measurement write hard-evicted the whole
${userId}| prefix. A high-frequency iOS Apple-Health sync therefore
busted the snapshot into a cold rebuild on the next read all day long
(the same class as the v1.8.7 insight-cache eviction).

Give the analytics bucket a stale-while-revalidate window, read the
snapshot via cachedSwr, and switch the measurement-write invalidation
from a hard deleteByPrefix to markStaleByPrefix. The snapshot now serves
the prior value immediately while a single background recompute warms a
fresh one. Slim / thick / summary cells read via plain cached are
unaffected: a marked-stale entry has expiresAt === now, so their next
read is a clean miss and rebuilds fresh. Widget reorder keeps its hard
point-evict so a layout change paints synchronously; mood and medication
writes keep their hard analytics sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:51:54 +02:00

526 lines
18 KiB
TypeScript

/**
* v1.4.34 IW-G — in-process LRU + single-flight server cache.
*
* Generalises the v1.4.33 Coach snapshot cache shape
* (`src/lib/ai/coach/snapshot.ts:292-336`, commit `af17db5d`) into a
* reusable primitive. Same recipe: `Map<string, { expiresAt, value }>`
* gives us insertion-order iteration for LRU touch, a hard cap on
* entries to bound memory, and a `pending` map so concurrent reads of
* the same cold key fan into a single builder call.
*
* Design notes:
*
* - LRU touch is `delete + set` so re-insertion moves the key to the
* end of the Map's iteration order. Eviction reads the first key
* (least-recently-touched) via `map.keys().next().value`.
*
* - `wrap(key, builder)` is the only public read path. Misses register
* the in-flight promise on `pending` before awaiting the builder so
* a second caller for the same key inside the build window awaits
* the same promise instead of starting a duplicate read. Rejected
* promises remove themselves from `pending` so a transient failure
* doesn't poison the key.
*
* - Metrics are per-instance: hits, misses, evictions, stampedes (a
* caller that hit the single-flight join), and capacity (current
* entry count). Read these via `stats()` for the wide-event
* annotations the route handlers attach to each request.
*
* - The cache is process-local. A multi-instance Coolify deploy keeps
* each container's cache isolated; the TTL bounds the staleness
* window. The Redis migration path is sketched in the blueprint
* (`.planning/research/v1434-r-cache-aggregation.md` §7).
*
* - `__resetForTests()` matches the snapshot pattern's escape hatch so
* vitest's afterEach can clear every instance between tests without
* reaching into the private `Map`.
*/
export interface ServerCacheOptions {
/** Hard cap on entries. Oldest entry is evicted on overflow. */
readonly maxEntries: number;
/** Time-to-live in milliseconds. Expired entries miss on read. */
readonly ttlMs: number;
/**
* v1.12.1 — stale-while-revalidate window in milliseconds. When set,
* an entry whose `ttlMs` has lapsed but is still inside
* `ttlMs + staleTtlMs` is served immediately (stale) by `wrapSwr()`,
* which kicks off a single background recompute to refresh it. Past
* the stale window the entry is a hard miss like any other. Leave
* unset for the legacy hard-TTL behaviour.
*/
readonly staleTtlMs?: number;
/**
* Optional name for observability — surfaced in `stats().name` so the
* wide-event meta keys can carry the cache identity without the
* caller having to remember which `caches.*` instance it pulled.
*/
readonly name?: string;
}
export interface ServerCacheStats {
readonly name: string;
readonly size: number;
readonly hits: number;
readonly misses: number;
readonly evictions: number;
readonly stampedes: number;
}
export class ServerCache<T> {
private readonly map = new Map<
string,
{ expiresAt: number; staleUntil: number; value: T }
>();
private readonly pending = new Map<string, Promise<T>>();
private hits = 0;
private misses = 0;
private evictions = 0;
private stampedes = 0;
constructor(private readonly opts: ServerCacheOptions) {}
/**
* Read a key. Returns null on miss, expired entry, or absent entry.
* Side effect: an expired entry is removed; a live entry is
* LRU-touched (re-inserted) so subsequent reads keep it warm.
*
* Does NOT increment `hits`/`misses` — those live on the
* `wrap()` path because the bare `get` is also called from tests
* and from the test escape hatch.
*/
get(key: string): T | null {
const entry = this.map.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
this.map.delete(key);
return null;
}
// LRU touch — re-insertion moves the key to the end of the Map's
// iteration order (which is the most-recently-used end).
this.map.delete(key);
this.map.set(key, entry);
return entry.value;
}
/**
* Insert or overwrite. Evicts the oldest entry if the cap is hit.
*
* `ttlMsOverride` lets a single key carry a longer (or shorter) TTL
* than the bucket default without splitting it into a separate cache
* instance — used by the dashboard-snapshot key, which must outlive
* the 60 s analytics default so the client's 120 s refetch interval
* lands on a warm entry, while still sharing the bucket's `${userId}|`
* prefix-sweep invalidation.
*/
set(key: string, value: T, ttlMsOverride?: number): void {
// The key might already exist (eviction-by-set is a write through
// the same slot — no cap eviction needed). Delete-then-set to keep
// the LRU ordering predictable.
if (this.map.has(key)) {
this.map.delete(key);
} else if (this.map.size >= this.opts.maxEntries) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) {
this.map.delete(oldest);
this.evictions += 1;
}
}
const now = Date.now();
const expiresAt = now + (ttlMsOverride ?? this.opts.ttlMs);
this.map.set(key, {
expiresAt,
// The stale window extends past the fresh TTL only when the
// bucket opted into SWR; otherwise `staleUntil === expiresAt`
// so `getAllowStale` behaves exactly like `get`.
staleUntil: expiresAt + (this.opts.staleTtlMs ?? 0),
value,
});
}
/**
* v1.12.1 — read a key allowing a stale entry inside the SWR window.
* Returns the value plus a `stale` flag (true when the fresh TTL has
* lapsed but the entry is still inside `staleTtlMs`). Past the stale
* window the entry is removed and `null` is returned. LRU-touches a
* live or stale entry so a hot key never falls out under pressure.
*/
getAllowStale(key: string): { value: T; stale: boolean } | null {
const entry = this.map.get(key);
if (!entry) return null;
const now = Date.now();
if (entry.staleUntil <= now) {
this.map.delete(key);
return null;
}
// LRU touch.
this.map.delete(key);
this.map.set(key, entry);
return { value: entry.value, stale: entry.expiresAt <= now };
}
/**
* v1.12.1 — mark every entry under `prefix` stale without dropping it.
* Collapses the fresh TTL to "now" so the next `wrapSwr` read serves
* the prior value immediately (within the stale window) while a single
* coalesced recompute warms a fresh value. This replaces the
* hard-evict on a mood write so an active logger no longer re-pays the
* cold compute on every entry. No-op for buckets without `staleTtlMs`
* (the entry is already at/over its hard TTL after this, so the next
* read is a clean miss — identical to the old evict behaviour).
*/
markStaleByPrefix(prefix: string): number {
let marked = 0;
const now = Date.now();
for (const [key, entry] of this.map) {
if (key.startsWith(prefix)) {
entry.expiresAt = now;
marked += 1;
}
}
return marked;
}
/** Single-key eviction. Returns true when the key was present. */
delete(key: string): boolean {
return this.map.delete(key);
}
/**
* Bulk eviction. Removes every entry whose key starts with `prefix`.
* Returns the count of removed entries.
*
* The invalidation helpers in `./invalidate.ts` use this to flush an
* entire user-bucket from a cache without having to enumerate every
* possible cache-key variant the user might have warmed.
*/
deleteByPrefix(prefix: string): number {
let removed = 0;
for (const key of this.map.keys()) {
if (key.startsWith(prefix)) {
this.map.delete(key);
removed += 1;
}
}
return removed;
}
/**
* Read-through + single-flight. On a live hit, returns the cached
* value. On a miss, either joins an in-flight builder (single-flight)
* or kicks off a new builder, stores the result, and returns it.
*
* Builder rejections do NOT poison the cache — the `pending` entry
* is removed on reject so the next caller retries.
*/
async wrap(
key: string,
builder: () => Promise<T>,
ttlMsOverride?: number,
): Promise<{
value: T;
outcome: "hit" | "miss" | "stampede";
}> {
const live = this.get(key);
if (live !== null) {
this.hits += 1;
return { value: live, outcome: "hit" };
}
const inFlight = this.pending.get(key);
if (inFlight) {
this.stampedes += 1;
const value = await inFlight;
return { value, outcome: "stampede" };
}
this.misses += 1;
const promise = builder()
.then((value) => {
this.set(key, value, ttlMsOverride);
return value;
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, promise);
const value = await promise;
return { value, outcome: "miss" };
}
/**
* v1.12.1 — stale-while-revalidate read. On a fresh hit it returns the
* cached value like `wrap`. On a stale hit (entry past its fresh TTL
* but inside the SWR window) it returns the prior value IMMEDIATELY
* and kicks off a single background recompute — the active caller
* never pays the cold compute. On a hard miss / cold key it falls back
* to `wrap`'s single-flight builder and awaits it.
*
* Background revalidation reuses the `pending` single-flight slot so a
* burst of stale reads coalesces into one recompute. A rejected
* background build is swallowed (the stale value already served) and
* clears `pending` so the next read retries.
*/
async wrapSwr(
key: string,
builder: () => Promise<T>,
ttlMsOverride?: number,
): Promise<{
value: T;
outcome: "hit" | "stale" | "miss" | "stampede";
}> {
const cachedEntry = this.getAllowStale(key);
if (cachedEntry && !cachedEntry.stale) {
this.hits += 1;
return { value: cachedEntry.value, outcome: "hit" };
}
if (cachedEntry && cachedEntry.stale) {
// Serve stale now; revalidate in the background under the
// single-flight slot so concurrent stale reads share one rebuild.
this.hits += 1;
if (!this.pending.has(key)) {
const refresh = builder()
.then((value) => {
this.set(key, value, ttlMsOverride);
return value;
})
.catch((err) => {
// The stale value already went out; don't crash the worker.
// Re-throw so the join path of any awaiting caller sees it.
throw err;
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, refresh);
// Detach: the foreground caller does not await the rebuild.
// Swallow the rejection on the detached handle so an
// unhandled-rejection warning never fires.
refresh.catch(() => {});
}
return { value: cachedEntry.value, outcome: "stale" };
}
// Hard miss / cold key — fall back to the awaited single-flight path.
return this.wrap(key, builder, ttlMsOverride);
}
/** Snapshot of per-instance counters for the wide-event annotation. */
stats(): ServerCacheStats {
return {
name: this.opts.name ?? "unnamed",
size: this.map.size,
hits: this.hits,
misses: this.misses,
evictions: this.evictions,
stampedes: this.stampedes,
};
}
/** Reset every counter + entry. Test-only escape hatch. */
__resetForTests(): void {
this.map.clear();
this.pending.clear();
this.hits = 0;
this.misses = 0;
this.evictions = 0;
this.stampedes = 0;
}
}
/**
* Per-route cache instances. Module-scope so every request lands in the
* same `Map`. Sizes match the blueprint §5 table.
*
* The blueprint's full eight-cache roster is provisioned so future
* rounds can wire the remaining routes (`/api/medications`,
* `/api/dashboard/widgets`, `/api/bugreport/status`, `/api/workouts`,
* `/api/mood/analytics`) without churning the helper module.
*/
export const caches = {
/**
* Slim / thick analytics cells, the iOS summary cell, the dashboard
* summary, AND the unified dashboard first-paint snapshot (per-key TTL
* override). 60 s fresh TTL keeps multiple sub-page mounts inside a
* minute on a warm entry.
*
* v1.12.7 — stale-while-revalidate window. The snapshot read uses
* `cachedSwr` and a measurement write marks the bucket stale (not a
* hard evict) via `invalidateUserMeasurements`, so a high-frequency
* iOS Apple-Health sync no longer busts the snapshot into a cold
* rebuild on every batch (the v1.8.7 "sync evicting the cache all day"
* class, on the dashboard snapshot). Slim / thick / summary cells read
* via plain `cached` are unaffected: a marked-stale entry has
* `expiresAt === now`, so their next `cached` read is a clean miss and
* rebuilds fresh — only the `cachedSwr` snapshot serves stale + warms a
* background recompute. The 10-minute stale window bounds how old a
* served snapshot can be when no reader triggers a revalidation.
*/
analytics: new ServerCache<unknown>({
name: "analytics",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
medications: new ServerCache<unknown>({
name: "medications",
maxEntries: 1000,
ttlMs: 60_000,
}),
achievements: new ServerCache<unknown>({
name: "achievements",
maxEntries: 1000,
ttlMs: 60_000,
}),
dashboardWidgets: new ServerCache<unknown>({
name: "dashboardWidgets",
maxEntries: 500,
ttlMs: 300_000,
}),
bugreportStatus: new ServerCache<unknown>({
name: "bugreportStatus",
maxEntries: 10,
ttlMs: 600_000,
}),
workouts: new ServerCache<unknown>({
name: "workouts",
maxEntries: 1000,
ttlMs: 60_000,
}),
medicationsIntake: new ServerCache<unknown>({
name: "medicationsIntake",
maxEntries: 1000,
ttlMs: 900_000,
}),
moodAnalytics: new ServerCache<unknown>({
name: "moodAnalytics",
maxEntries: 1000,
ttlMs: 60_000,
}),
/**
* v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,
* weekday, tags, cross-metric correlations) for the Mood Insights page.
* Read on every `/insights/mood` mount; bounded 365-day live read behind
* the cache. 60 s fresh TTL matches `moodAnalytics`.
*
* v1.12.1 — stale-while-revalidate. The read route uses `cachedSwr` and
* a mood write marks the bucket stale (not a hard evict) via
* `invalidateUserMood`. An active logger now serves the prior aggregate
* immediately while a single background recompute warms a fresh one,
* instead of re-paying the multi-second cold compute on every entry
* (the v1.8.7 "sync evicting the cache all day" class, on mood). The
* 10-minute stale window bounds how old a served aggregate can be when
* no reader has triggered a revalidation.
*/
moodInsights: new ServerCache<unknown>({
name: "moodInsights",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
/**
* v1.4.36 W1 — `/api/insights/targets` is read on every Insights
* mount and runs >1 s cold (it pulls 30-day measurements for every
* type + medications + intakes + mood + glucose). 60 s TTL matches
* the analytics cache so multiple sub-page mounts inside a minute
* all hit a warm cache. Invalidated alongside the analytics bucket
* on measurement / mood / medication writes.
*/
insightsTargets: new ServerCache<unknown>({
name: "insightsTargets",
maxEntries: 1000,
ttlMs: 60_000,
}),
/**
* v1.5.5 — per-user insights tile layout cache. Mirrors
* `dashboardWidgets` (same 5-minute TTL, same per-user bucket size).
* Read on every `/insights` mount; the layout only changes on a
* Settings save, which invalidates this cache via
* `invalidateUserInsightsLayout()`.
*/
insightsLayout: new ServerCache<unknown>({
name: "insightsLayout",
maxEntries: 500,
ttlMs: 300_000,
}),
} as const;
/**
* Non-reversible 32-bit hash of the cache key. Used in wide-event meta
* so we can correlate hit / miss patterns without leaking the raw
* userId portion of the key into logs.
*
* djb2 — public domain, two lines, matches the blueprint §8 sketch.
*/
export function hashCacheKey(key: string): number {
let hash = 5381;
for (let i = 0; i < key.length; i += 1) {
hash = ((hash << 5) + hash + key.charCodeAt(i)) | 0;
}
return hash >>> 0;
}
/**
* Read-through wrapper that also annotates the active wide-event with
* the cache hit / miss / stampede outcome and a key hash. Matches the
* observability contract in the blueprint §8.
*
* Falls back gracefully when no logging context is active (the
* `annotate()` call is a no-op outside of `apiHandler`).
*/
export async function cached<T>(
cache: ServerCache<T>,
key: string,
builder: () => Promise<T>,
annotateFn?: (fields: { meta: Record<string, unknown> }) => void,
ttlMsOverride?: number,
): Promise<T> {
const { value, outcome } = await cache.wrap(key, builder, ttlMsOverride);
if (annotateFn) {
const name = cache.stats().name;
annotateFn({
meta: {
[`cache.${name}.outcome`]: outcome,
[`cache.${name}.key_hash`]: hashCacheKey(key),
},
});
}
return value;
}
/**
* v1.12.1 — stale-while-revalidate read-through. Same observability
* contract as `cached` (annotates the wide-event with the outcome +
* key hash) but routes through `wrapSwr`: a stale entry inside the
* bucket's `staleTtlMs` window is served immediately while a single
* background recompute refreshes it, so an active caller never pays
* the cold compute on an expired or freshly-marked-stale bucket.
*/
export async function cachedSwr<T>(
cache: ServerCache<T>,
key: string,
builder: () => Promise<T>,
annotateFn?: (fields: { meta: Record<string, unknown> }) => void,
ttlMsOverride?: number,
): Promise<T> {
const { value, outcome } = await cache.wrapSwr(key, builder, ttlMsOverride);
if (annotateFn) {
const name = cache.stats().name;
annotateFn({
meta: {
[`cache.${name}.outcome`]: outcome,
[`cache.${name}.key_hash`]: hashCacheKey(key),
},
});
}
return value;
}
/** Test helper — reset every cache in the registry. */
export function __resetAllCachesForTests(): void {
for (const cache of Object.values(caches)) {
cache.__resetForTests();
}
}