mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(cache): server-side LRU primitive + per-user invalidation helpers
Extends the v1.4.33 Coach snapshot LRU shape into a reusable ServerCache<T> with TTL expiry, capacity-bounded LRU eviction, single-flight read coalescing, prefix delete, and per-instance hit/miss/eviction/stampede counters. Adds five invalidation helpers keyed by the per-user prefix scheme so write endpoints can flush every cache that may reflect their changed rows in a single call. The cached() wrapper attaches cache.<name>.outcome + cache.<name>.key_hash to the active wide event so production logs carry the hit ratio without leaking the userId portion of the cache key. Hand-rolled to keep the dependency surface at zero — same recipe as the snapshot cache the dashboard already relies on. Process-local; multi- instance staleness is bounded by TTL, the Redis migration path stays a future option.
This commit is contained in:
1 parent
fef24a89bb
commit
a5dd8e1d1a
7 files changed
+755
No files matched your search
@@ -386,6 +386,7 @@ navigates to a feature that needs it (R-F §2.5 option 2).
|
||||
| Categorisation shape | UI-side TypeScript overlay `src/lib/measurements/categories.ts`, NOT a DB column | R-F §4 |
|
||||
| Sleep storage shape | Per-stage rows (5-axis unique key) — no split into `SLEEP_DEEP` / `SLEEP_REM` / etc. enum entries | R-F open Q #6 |
|
||||
| Web freeze trigger | v1.4.34 tag on `main` (after the Apple Health XML import lands) | This plan §2, R2 |
|
||||
| v1.4.34 ships web-freeze marker | tag on main | this plan §2 |
|
||||
| v1.5 release shape | Version-bump-only marker the day after iOS clears Apple review. All functional work lands incrementally in v1.4.29 → v1.4.34. | Maintainer directive 2026-05-16 |
|
||||
| Web patch sequence | v1.4.29 polish → v1.4.29.1 step hotfix → v1.4.30 iOS-coordinated foundation (Daily-Stats + SyncMode) → v1.4.30.1 categories endpoint + conflict-resolution lock → v1.4.31 toggles + insights blocking + Coolify auto-deploy → v1.4.32 Tier 1 wave A → v1.4.33 Tier 1 wave B → v1.4.34 freeze | This plan §2, maintainer directive 2026-05-16 + R1 + R2 |
|
||||
| Apple Health XML import slot | v1.4.34 (before freeze) | R2 + maintainer 2026-05-16 |
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
imports on behalf of a target user (cookie-only `requireAdmin()`
|
||||
gate; Bearer tokens never elevate).
|
||||
|
||||
### Web freeze
|
||||
|
||||
v1.4.34 marks the last functional release of the web codebase before
|
||||
the iOS native client launches. Subsequent v1.4.x tags carry security
|
||||
fixes, dependency bumps, and hotfix-only corrections. New feature work
|
||||
is paused until the iOS app clears Apple review, at which point a
|
||||
v1.5.0 version-bump-only release tags the milestone.
|
||||
|
||||
## [1.4.33] — 2026-05-17 — Polish and reliability
|
||||
|
||||
Quality-leap release between two HealthKit milestones. The headline is
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// v1.4.34 web-freeze. Schema is locked additive-only until iOS launch
|
||||
// + v1.5.0 marker. See .planning/v15-strategic-plan.md §2.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Unit tests for the per-user invalidation helpers — every helper
|
||||
* evicts the matching `caches.*` bucket and leaves unrelated entries
|
||||
* intact.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
__resetAllCachesForTests,
|
||||
cached,
|
||||
caches,
|
||||
} from "../server-cache";
|
||||
import {
|
||||
invalidateAppSettings,
|
||||
invalidateUserDashboardWidgets,
|
||||
invalidateUserMeasurements,
|
||||
invalidateUserMedications,
|
||||
invalidateUserMood,
|
||||
} from "../invalidate";
|
||||
|
||||
const USER_A = "user-a";
|
||||
const USER_B = "user-b";
|
||||
|
||||
beforeEach(() => {
|
||||
__resetAllCachesForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetAllCachesForTests();
|
||||
});
|
||||
|
||||
async function primeAllCaches(): Promise<void> {
|
||||
// Analytics keys carry a slice suffix; the prefix-match must include
|
||||
// the pipe so user-1 doesn't match user-10 in production traffic.
|
||||
await cached(caches.analytics, `${USER_A}|default`, async () => ({ a: 1 }));
|
||||
await cached(caches.analytics, `${USER_A}|summaries`, async () => ({ a: 2 }));
|
||||
await cached(caches.analytics, `${USER_B}|default`, async () => ({ b: 1 }));
|
||||
|
||||
await cached(caches.medications, USER_A, async () => ({ m: 1 }));
|
||||
await cached(caches.medications, USER_B, async () => ({ m: 2 }));
|
||||
|
||||
await cached(caches.medicationsIntake, `${USER_A}|compliance|30`, async () => ({
|
||||
c: 1,
|
||||
}));
|
||||
await cached(caches.medicationsIntake, `${USER_B}|compliance|30`, async () => ({
|
||||
c: 2,
|
||||
}));
|
||||
|
||||
await cached(caches.achievements, USER_A, async () => ({ ach: 1 }));
|
||||
await cached(caches.achievements, USER_B, async () => ({ ach: 2 }));
|
||||
|
||||
await cached(caches.workouts, `${USER_A}|3|0||`, async () => ({ w: 1 }));
|
||||
await cached(caches.workouts, `${USER_B}|3|0||`, async () => ({ w: 2 }));
|
||||
|
||||
await cached(caches.moodAnalytics, USER_A, async () => ({ mood: 1 }));
|
||||
await cached(caches.moodAnalytics, USER_B, async () => ({ mood: 2 }));
|
||||
|
||||
await cached(caches.dashboardWidgets, USER_A, async () => ({ d: 1 }));
|
||||
await cached(caches.dashboardWidgets, USER_B, async () => ({ d: 2 }));
|
||||
|
||||
await cached(caches.bugreportStatus, "singleton", async () => ({ s: 1 }));
|
||||
}
|
||||
|
||||
describe("invalidateUserMeasurements", () => {
|
||||
it("evicts analytics + achievements + workouts for the target user only", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateUserMeasurements(USER_A);
|
||||
|
||||
expect(caches.analytics.get(`${USER_A}|default`)).toBeNull();
|
||||
expect(caches.analytics.get(`${USER_A}|summaries`)).toBeNull();
|
||||
expect(caches.achievements.get(USER_A)).toBeNull();
|
||||
expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull();
|
||||
|
||||
// User B's caches untouched.
|
||||
expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull();
|
||||
expect(caches.achievements.get(USER_B)).not.toBeNull();
|
||||
expect(caches.workouts.get(`${USER_B}|3|0||`)).not.toBeNull();
|
||||
|
||||
// Mood-analytics not touched by a measurement write.
|
||||
expect(caches.moodAnalytics.get(USER_A)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateUserMood", () => {
|
||||
it("evicts mood-analytics + achievements + analytics", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateUserMood(USER_A);
|
||||
|
||||
expect(caches.moodAnalytics.get(USER_A)).toBeNull();
|
||||
expect(caches.achievements.get(USER_A)).toBeNull();
|
||||
expect(caches.analytics.get(`${USER_A}|default`)).toBeNull();
|
||||
expect(caches.moodAnalytics.get(USER_B)).not.toBeNull();
|
||||
expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateUserMedications", () => {
|
||||
it("evicts medications + medications-intake + achievements", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateUserMedications(USER_A);
|
||||
|
||||
expect(caches.medications.get(USER_A)).toBeNull();
|
||||
expect(caches.medicationsIntake.get(`${USER_A}|compliance|30`)).toBeNull();
|
||||
expect(caches.achievements.get(USER_A)).toBeNull();
|
||||
expect(caches.medications.get(USER_B)).not.toBeNull();
|
||||
expect(caches.medicationsIntake.get(`${USER_B}|compliance|30`)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateUserDashboardWidgets", () => {
|
||||
it("evicts only the dashboardWidgets bucket for the target user", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateUserDashboardWidgets(USER_A);
|
||||
expect(caches.dashboardWidgets.get(USER_A)).toBeNull();
|
||||
expect(caches.dashboardWidgets.get(USER_B)).not.toBeNull();
|
||||
// Sibling caches untouched.
|
||||
expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateAppSettings", () => {
|
||||
it("evicts the entire bug-report status cache (singleton row)", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateAppSettings();
|
||||
expect(caches.bugreportStatus.get("singleton")).toBeNull();
|
||||
// Per-user caches untouched.
|
||||
expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Unit tests for the `ServerCache<T>` primitive — LRU ordering, TTL
|
||||
* expiry, capacity-cap eviction, prefix delete, single-flight
|
||||
* coalescing, builder-rejection cleanup, and the `cached()` wrapper's
|
||||
* observability annotation.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
__resetAllCachesForTests,
|
||||
cached,
|
||||
caches,
|
||||
hashCacheKey,
|
||||
ServerCache,
|
||||
} from "../server-cache";
|
||||
|
||||
afterEach(() => {
|
||||
__resetAllCachesForTests();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ServerCache.get / set", () => {
|
||||
it("returns the stored value within TTL", () => {
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
|
||||
cache.set("k", "v");
|
||||
expect(cache.get("k")).toBe("v");
|
||||
});
|
||||
|
||||
it("returns null when the key is absent", () => {
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
|
||||
expect(cache.get("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("expires entries past the TTL", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 1000 });
|
||||
cache.set("k", "v");
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(cache.get("k")).toBe("v");
|
||||
vi.advanceTimersByTime(2);
|
||||
expect(cache.get("k")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerCache LRU eviction", () => {
|
||||
it("evicts the oldest entry on capacity overflow", () => {
|
||||
const cache = new ServerCache<number>({ maxEntries: 3, ttlMs: 60_000 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.set("c", 3);
|
||||
cache.set("d", 4); // evicts "a"
|
||||
expect(cache.get("a")).toBeNull();
|
||||
expect(cache.get("b")).toBe(2);
|
||||
expect(cache.get("c")).toBe(3);
|
||||
expect(cache.get("d")).toBe(4);
|
||||
expect(cache.stats().evictions).toBe(1);
|
||||
});
|
||||
|
||||
it("touches on read so least-recently-used is evicted", () => {
|
||||
const cache = new ServerCache<number>({ maxEntries: 3, ttlMs: 60_000 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.set("c", 3);
|
||||
// Touch "a" — now "b" is the oldest.
|
||||
expect(cache.get("a")).toBe(1);
|
||||
cache.set("d", 4); // evicts "b"
|
||||
expect(cache.get("a")).toBe(1);
|
||||
expect(cache.get("b")).toBeNull();
|
||||
expect(cache.get("c")).toBe(3);
|
||||
expect(cache.get("d")).toBe(4);
|
||||
});
|
||||
|
||||
it("re-setting an existing key does not count toward eviction", () => {
|
||||
const cache = new ServerCache<number>({ maxEntries: 2, ttlMs: 60_000 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.set("a", 99); // overwrite, no eviction
|
||||
expect(cache.stats().evictions).toBe(0);
|
||||
expect(cache.get("a")).toBe(99);
|
||||
expect(cache.get("b")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerCache.deleteByPrefix", () => {
|
||||
it("removes every key with a matching prefix", () => {
|
||||
const cache = new ServerCache<number>({ maxEntries: 16, ttlMs: 60_000 });
|
||||
cache.set("user-1|analytics", 1);
|
||||
cache.set("user-1|workouts", 2);
|
||||
cache.set("user-2|analytics", 3);
|
||||
const removed = cache.deleteByPrefix("user-1|");
|
||||
expect(removed).toBe(2);
|
||||
expect(cache.get("user-1|analytics")).toBeNull();
|
||||
expect(cache.get("user-1|workouts")).toBeNull();
|
||||
expect(cache.get("user-2|analytics")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 0 when no keys match", () => {
|
||||
const cache = new ServerCache<number>({ maxEntries: 16, ttlMs: 60_000 });
|
||||
cache.set("user-1|analytics", 1);
|
||||
expect(cache.deleteByPrefix("user-99|")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerCache.wrap single-flight", () => {
|
||||
it("calls the builder once for two concurrent reads of the same key", async () => {
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
|
||||
let callCount = 0;
|
||||
let resolveBuilder: ((value: string) => void) | null = null;
|
||||
const builder = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
callCount += 1;
|
||||
resolveBuilder = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const first = cache.wrap("k", builder);
|
||||
const second = cache.wrap("k", builder);
|
||||
|
||||
// Both reads have landed before the builder resolves.
|
||||
expect(callCount).toBe(1);
|
||||
resolveBuilder!("built");
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult.value).toBe("built");
|
||||
expect(firstResult.outcome).toBe("miss");
|
||||
expect(secondResult.value).toBe("built");
|
||||
expect(secondResult.outcome).toBe("stampede");
|
||||
expect(builder).toHaveBeenCalledTimes(1);
|
||||
expect(cache.stats().stampedes).toBe(1);
|
||||
});
|
||||
|
||||
it("returns the cached value as a hit on the third read", async () => {
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
|
||||
const builder = vi.fn(async () => "built");
|
||||
await cache.wrap("k", builder);
|
||||
const third = await cache.wrap("k", builder);
|
||||
expect(third.value).toBe("built");
|
||||
expect(third.outcome).toBe("hit");
|
||||
expect(builder).toHaveBeenCalledTimes(1);
|
||||
expect(cache.stats().hits).toBe(1);
|
||||
});
|
||||
|
||||
it("clears the pending slot on builder rejection so the next call retries", async () => {
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
|
||||
let attempt = 0;
|
||||
const builder = vi.fn(async () => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new Error("transient");
|
||||
return "ok";
|
||||
});
|
||||
|
||||
await expect(cache.wrap("k", builder)).rejects.toThrow("transient");
|
||||
const next = await cache.wrap("k", builder);
|
||||
expect(next.value).toBe("ok");
|
||||
expect(next.outcome).toBe("miss");
|
||||
expect(builder).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cached() observability wrapper", () => {
|
||||
it("annotates hit / miss outcome and a hashed key", async () => {
|
||||
const cache = new ServerCache<string>({
|
||||
name: "test",
|
||||
maxEntries: 4,
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
const annotate = vi.fn();
|
||||
const builder = vi.fn(async () => "v");
|
||||
|
||||
await cached(cache, "user-1|default", builder, annotate);
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
meta: {
|
||||
"cache.test.outcome": "miss",
|
||||
"cache.test.key_hash": hashCacheKey("user-1|default"),
|
||||
},
|
||||
});
|
||||
|
||||
annotate.mockClear();
|
||||
await cached(cache, "user-1|default", builder, annotate);
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
meta: expect.objectContaining({
|
||||
"cache.test.outcome": "hit",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("no-ops the annotation when no annotator is passed", async () => {
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
|
||||
const value = await cached(cache, "k", async () => "v");
|
||||
expect(value).toBe("v");
|
||||
});
|
||||
});
|
||||
|
||||
describe("global cache registry", () => {
|
||||
it("exposes the eight blueprint caches as module-scope singletons", () => {
|
||||
expect(caches.analytics).toBeDefined();
|
||||
expect(caches.medications).toBeDefined();
|
||||
expect(caches.achievements).toBeDefined();
|
||||
expect(caches.dashboardWidgets).toBeDefined();
|
||||
expect(caches.bugreportStatus).toBeDefined();
|
||||
expect(caches.workouts).toBeDefined();
|
||||
expect(caches.medicationsIntake).toBeDefined();
|
||||
expect(caches.moodAnalytics).toBeDefined();
|
||||
});
|
||||
|
||||
it("__resetAllCachesForTests clears every registry instance", async () => {
|
||||
await cached(caches.analytics, "user-1|default", async () => ({ ok: 1 }));
|
||||
expect(caches.analytics.stats().size).toBe(1);
|
||||
__resetAllCachesForTests();
|
||||
expect(caches.analytics.stats().size).toBe(0);
|
||||
expect(caches.analytics.stats().hits).toBe(0);
|
||||
expect(caches.analytics.stats().misses).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashCacheKey", () => {
|
||||
it("is deterministic and unsigned 32-bit", () => {
|
||||
const h1 = hashCacheKey("user-1|default");
|
||||
const h2 = hashCacheKey("user-1|default");
|
||||
expect(h1).toBe(h2);
|
||||
expect(h1).toBeGreaterThanOrEqual(0);
|
||||
expect(h1).toBeLessThanOrEqual(0xffffffff);
|
||||
});
|
||||
|
||||
it("distinguishes near-identical keys", () => {
|
||||
expect(hashCacheKey("user-1|default")).not.toBe(
|
||||
hashCacheKey("user-2|default"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* v1.4.34 IW-G — per-user cache invalidation helpers.
|
||||
*
|
||||
* Each write endpoint that touches a measurement / mood / medication /
|
||||
* dashboard-widget row calls the matching helper after its successful
|
||||
* database commit. The helpers walk the affected `caches.*` maps and
|
||||
* delete every key whose `userId` prefix matches.
|
||||
*
|
||||
* The "evict the whole user-bucket" stance is intentional: a redundant
|
||||
* eviction costs the next reader one cache miss (cheap, the row's
|
||||
* already paginated and the snapshot LRU sits behind), but an
|
||||
* under-eviction surfaces stale data which is the directive's failure
|
||||
* mode. The full per-write → per-cache fan-out is documented in the
|
||||
* blueprint §6 (`.planning/research/v1434-r-cache-aggregation.md`).
|
||||
*
|
||||
* The helpers are intentionally synchronous — they do `Map.delete()`
|
||||
* over a bounded entry set, which is cheap enough to run inline before
|
||||
* the write endpoint's `apiSuccess()` return. No `await` overhead.
|
||||
*/
|
||||
|
||||
import { caches } from "./server-cache";
|
||||
|
||||
/**
|
||||
* Invalidate every cache that may reflect a user's measurement set.
|
||||
*
|
||||
* Covers: analytics aggregate, achievement progress, workouts cache.
|
||||
* Mood-analytics is invalidated separately via `invalidateUserMood`
|
||||
* because mood writes don't change measurement rows.
|
||||
*/
|
||||
export function invalidateUserMeasurements(userId: string): void {
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
caches.achievements.deleteByPrefix(userId);
|
||||
caches.workouts.deleteByPrefix(`${userId}|`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate every cache that may reflect a user's mood entries.
|
||||
*
|
||||
* Mood writes also dirty the analytics aggregate (correlations runner
|
||||
* pairs mood × pulse) and achievement progress (consistent-month / mood
|
||||
* badges).
|
||||
*/
|
||||
export function invalidateUserMood(userId: string): void {
|
||||
caches.moodAnalytics.deleteByPrefix(userId);
|
||||
caches.achievements.deleteByPrefix(userId);
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate every cache that may reflect a user's medication state.
|
||||
*
|
||||
* Covers the medications cache, the medication-intake compliance cache
|
||||
* (cached daily), and achievement progress.
|
||||
*/
|
||||
export function invalidateUserMedications(userId: string): void {
|
||||
caches.medications.deleteByPrefix(userId);
|
||||
caches.medicationsIntake.deleteByPrefix(`${userId}|`);
|
||||
caches.achievements.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the dashboard-widget layout cache for a single user.
|
||||
*
|
||||
* Called from the dashboard-widgets PUT / DELETE endpoints when the
|
||||
* user reorders / disables a tile.
|
||||
*/
|
||||
export function invalidateUserDashboardWidgets(userId: string): void {
|
||||
caches.dashboardWidgets.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global eviction of the bug-report-status cache.
|
||||
*
|
||||
* The cache is keyed on the singleton `"singleton"` slot because the
|
||||
* underlying data is a row in `AppSettings`. Any admin write to the
|
||||
* GitHub-token / bug-report-enabled fields invalidates the entire
|
||||
* cache; the per-user `isAdmin` flag is layered on at request time, so
|
||||
* the cache itself only stores role-agnostic shape.
|
||||
*/
|
||||
export function invalidateAppSettings(): void {
|
||||
caches.bugreportStatus.deleteByPrefix("");
|
||||
}
|
||||
Vendored
+299
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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; 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. */
|
||||
set(key: string, value: T): 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;
|
||||
}
|
||||
}
|
||||
this.map.set(key, {
|
||||
expiresAt: Date.now() + this.opts.ttlMs,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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>): 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);
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
this.pending.delete(key);
|
||||
});
|
||||
this.pending.set(key, promise);
|
||||
const value = await promise;
|
||||
return { value, outcome: "miss" };
|
||||
}
|
||||
|
||||
/** 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 = {
|
||||
analytics: new ServerCache<unknown>({
|
||||
name: "analytics",
|
||||
maxEntries: 1000,
|
||||
ttlMs: 60_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,
|
||||
}),
|
||||
} 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,
|
||||
): Promise<T> {
|
||||
const { value, outcome } = await cache.wrap(key, builder);
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user