release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
1 parent b01822be48
commit 62eb431d66
372 files changed
+25178 -8561

No files matched your search

+8 -2
View File
@@ -69,8 +69,14 @@ async function primeAllCaches(): Promise<void> {
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.workouts, `${USER_A}|3|0||`, async () => ({
canonical: [],
rawCount: 1,
}));
await cached(caches.workouts, `${USER_B}|3|0||`, async () => ({
canonical: [],
rawCount: 2,
}));
await cached(caches.moodAnalytics, USER_A, async () => ({ mood: 1 }));
await cached(caches.moodAnalytics, USER_B, async () => ({ mood: 2 }));
+109
View File
@@ -117,6 +117,102 @@ describe("ServerCache LRU eviction", () => {
expect(cache.get("b")).toBe(2);
});
});
describe("ServerCache weighted LRU eviction", () => {
it("evicts least-recently-used entries until the weight cap is satisfied", () => {
const cache = new ServerCache<string>({
maxEntries: 10,
maxWeight: 5,
ttlMs: 60_000,
weightOf: (value) => value.length,
});
cache.set("a", "aa");
cache.set("b", "bb");
expect(cache.get("a")).toBe("aa");
cache.set("c", "cc");
expect(cache.get("a")).toBe("aa");
expect(cache.get("b")).toBeNull();
expect(cache.get("c")).toBe("cc");
expect(cache.stats().evictions).toBe(1);
});
it("updates weight bookkeeping when replacing a resident key", () => {
const cache = new ServerCache<string>({
maxEntries: 10,
maxWeight: 3,
ttlMs: 60_000,
weightOf: (value) => value.length,
});
cache.set("a", "aa");
cache.set("b", "b");
cache.set("a", "a");
cache.set("c", "c");
expect(cache.get("a")).toBe("a");
expect(cache.get("b")).toBe("b");
expect(cache.get("c")).toBe("c");
expect(cache.stats().evictions).toBe(0);
});
it("normalizes zero and non-finite weights to one", () => {
const weights: Record<string, number> = {
zero: 0,
nan: Number.NaN,
infinity: Number.POSITIVE_INFINITY,
};
const cache = new ServerCache<string>({
maxEntries: 10,
maxWeight: 2,
ttlMs: 60_000,
weightOf: (_value, key) => weights[key] ?? 1,
});
cache.set("zero", "a");
cache.set("nan", "b");
cache.set("infinity", "c");
expect(cache.get("zero")).toBeNull();
expect(cache.get("nan")).toBe("b");
expect(cache.get("infinity")).toBe("c");
expect(cache.stats().evictions).toBe(1);
});
it("rejects an oversized entry without evicting resident entries", () => {
const cache = new ServerCache<string>({
maxEntries: 10,
maxWeight: 3,
ttlMs: 60_000,
weightOf: (value) => value.length,
});
cache.set("a", "a");
cache.set("b", "b");
cache.set("oversized", "four");
expect(cache.get("a")).toBe("a");
expect(cache.get("b")).toBe("b");
expect(cache.get("oversized")).toBeNull();
expect(cache.stats().size).toBe(2);
expect(cache.stats().evictions).toBe(0);
});
it("keeps the resident value when its oversized replacement is rejected", () => {
const cache = new ServerCache<string>({
maxEntries: 10,
maxWeight: 3,
ttlMs: 60_000,
weightOf: (value) => value.length,
});
cache.set("a", "a");
cache.set("a", "four");
expect(cache.get("a")).toBe("a");
expect(cache.stats().size).toBe(1);
expect(cache.stats().evictions).toBe(0);
});
});
describe("ServerCache.deleteByPrefix", () => {
it("removes every key with a matching prefix", () => {
@@ -621,6 +717,19 @@ describe("global cache registry", () => {
expect(caches.moodAnalytics).toBeDefined();
});
it("bounds workout projections by retained row weight", () => {
const mediumHistory = {
canonical: Array<unknown>(30_000),
rawCount: 30_000,
};
caches.workouts.set("user-1|first", mediumHistory as never);
caches.workouts.set("user-2|second", mediumHistory as never);
expect(caches.workouts.get("user-1|first")).toBeNull();
expect(caches.workouts.get("user-2|second")).not.toBeNull();
});
it("__resetAllCachesForTests clears every registry instance", async () => {
await cached(caches.analytics, "user-1|default", async () => ({ ok: 1 }));
expect(caches.analytics.stats().size).toBe(1);
+70 -21
View File
@@ -3,10 +3,9 @@
*
* 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.
* reusable primitive. A `Map` gives insertion-order iteration for LRU touch,
* entry-count and optional weighted caps bound memory, and a `pending` map
* lets concurrent reads of the same cold key share one builder call.
*
* Design notes:
*
@@ -36,11 +35,23 @@
* reaching into the private `Map`.
*/
export interface ServerCacheOptions {
import type { WorkoutsProjection } from "@/lib/workouts/list-read";
export interface ServerCacheOptions<T = unknown> {
/** 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 hard cap on the combined weight of resident entries.
* Entries are evicted from the LRU end until both caps are satisfied.
*/
readonly maxWeight?: number;
/**
* Returns a positive capacity weight for one entry. Defaults to 1, making
* `maxWeight` equivalent to a second entry-count cap when omitted.
*/
readonly weightOf?: (value: T, key: string) => number;
/**
* v1.12.1 — stale-while-revalidate window in milliseconds. When set,
* an entry whose `ttlMs` has lapsed but is still inside
@@ -88,15 +99,16 @@ interface PendingBuild<T> {
export class ServerCache<T> {
private readonly map = new Map<
string,
{ expiresAt: number; staleUntil: number; value: T }
{ expiresAt: number; staleUntil: number; value: T; weight: number }
>();
private readonly pending = new Map<string, PendingBuild<T>>();
private hits = 0;
private misses = 0;
private evictions = 0;
private stampedes = 0;
private totalWeight = 0;
constructor(private readonly opts: ServerCacheOptions) {}
constructor(private readonly opts: ServerCacheOptions<T>) {}
/**
* Fence + detach the in-flight builder for one key (no-op when none).
@@ -160,11 +172,18 @@ export class ServerCache<T> {
* `wrap()` path because the bare `get` is also called from tests
* and from the test escape hatch.
*/
private entryWeight(key: string, value: T): number {
if (this.opts.maxWeight === undefined) return 1;
const weight = this.opts.weightOf?.(value, key) ?? 1;
return Number.isFinite(weight) && weight > 0 ? Math.ceil(weight) : 1;
}
get(key: string): T | null {
const entry = this.map.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
this.map.delete(key);
this.totalWeight -= entry.weight;
return null;
}
// LRU touch — re-insertion moves the key to the end of the Map's
@@ -175,8 +194,8 @@ export class ServerCache<T> {
}
/**
* Insert or overwrite. Evicts the oldest entry if the cap is hit.
*
* Insert or overwrite. Evicts least-recently-used entries until every
* configured capacity limit is satisfied.
* `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
@@ -185,17 +204,16 @@ export class ServerCache<T> {
* 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)) {
const weight = this.entryWeight(key, value);
if (this.opts.maxWeight !== undefined && weight > this.opts.maxWeight) {
return;
}
// The key might already exist. Remove its old weight before replacing it,
// then reinsert at the most-recently-used end.
const existing = this.map.get(key);
if (existing) {
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.totalWeight -= existing.weight;
}
const now = Date.now();
const expiresAt = now + (ttlMsOverride ?? this.opts.ttlMs);
@@ -206,7 +224,22 @@ export class ServerCache<T> {
// so `getAllowStale` behaves exactly like `get`.
staleUntil: expiresAt + (this.opts.staleTtlMs ?? 0),
value,
weight,
});
this.totalWeight += weight;
while (
this.map.size > this.opts.maxEntries ||
(this.opts.maxWeight !== undefined &&
this.totalWeight > this.opts.maxWeight)
) {
const oldest = this.map.keys().next().value;
if (oldest === undefined) break;
const evicted = this.map.get(oldest);
this.map.delete(oldest);
this.totalWeight -= evicted?.weight ?? 0;
this.evictions += 1;
}
}
/**
@@ -222,6 +255,7 @@ export class ServerCache<T> {
const now = Date.now();
if (entry.staleUntil <= now) {
this.map.delete(key);
this.totalWeight -= entry.weight;
return null;
}
// LRU touch.
@@ -271,7 +305,10 @@ export class ServerCache<T> {
// v1.16.9 — fence the in-flight builder so a pre-write build can
// neither be joined by post-evict reads nor commit its result.
this.detachPending(key);
return this.map.delete(key);
const entry = this.map.get(key);
const removed = this.map.delete(key);
if (removed && entry) this.totalWeight -= entry.weight;
return removed;
}
/**
@@ -288,7 +325,9 @@ export class ServerCache<T> {
let removed = 0;
for (const key of this.map.keys()) {
if (key.startsWith(prefix)) {
const entry = this.map.get(key);
this.map.delete(key);
this.totalWeight -= entry?.weight ?? 0;
removed += 1;
}
}
@@ -409,6 +448,7 @@ export class ServerCache<T> {
/** Reset every counter + entry. Test-only escape hatch. */
__resetForTests(): void {
this.map.clear();
this.totalWeight = 0;
// Fence any in-flight builder so a build crossing a test boundary
// cannot commit into the next test's clean cache.
for (const slot of this.pending.values()) {
@@ -511,10 +551,19 @@ export const caches = {
maxEntries: 500,
ttlMs: 300_000,
}),
workouts: new ServerCache<unknown>({
// Retain at most 50k canonical workout rows across all filter projections.
// The selected row shape is fixed and narrow, so row count is a stable
// byte proxy without serializing or walking every field on each cache set.
// A projection larger than the entire budget is returned to its caller but
// immediately evicted rather than monopolizing process memory.
workouts: new ServerCache<WorkoutsProjection>({
name: "workouts",
maxEntries: 1000,
// A row-weight budget prevents a handful of full-history projections
// from looking as cheap as tiny lists under the entry-count cap.
maxWeight: 50_000,
ttlMs: 60_000,
weightOf: (projection) => Math.max(1, projection.canonical.length),
}),
medicationsIntake: new ServerCache<unknown>({
name: "medicationsIntake",