/** * Two-level response cache: in-process LRU (fast path) + Redis (shared between API replicas). * Key = route + normalized query. Invalidation is TTL-based only; admin writes bust the memory layer. */ import { getCacheRedis } from "./redis.js"; import { getEnv } from "./env.js"; import { metrics } from "./metrics.js"; const PREFIX = "dci:api:cache:"; const MAX_ENTRIES = Number(process.env.DCI_API_CACHE_ENTRIES ?? 800); interface Entry { value: string; expiresAt: number } const lru = new Map(); function memGet(key: string): string | null { const e = lru.get(key); if (!e) return null; if (e.expiresAt <= Date.now()) { lru.delete(key); return null; } lru.delete(key); lru.set(key, e); // refresh recency return e.value; } function memSet(key: string, value: string, ttlSec: number): void { if (lru.size >= MAX_ENTRIES) { const first = lru.keys().next().value; if (first !== undefined) lru.delete(first); } lru.set(key, { value, expiresAt: Date.now() + ttlSec * 1000 }); } export type CacheLayer = "memory" | "redis" | "miss" | "bypass"; export interface CacheResult { value: T; layer: CacheLayer } /** Compute-or-load. The value is JSON-serialized once and reused for both layers. */ export async function cached(key: string, ttlSec: number, compute: () => Promise): Promise> { if (!getEnv().cacheEnabled || ttlSec <= 0) return { value: await compute(), layer: "bypass" }; const mem = memGet(key); if (mem !== null) { metrics.cacheHit("memory"); return { value: JSON.parse(mem) as T, layer: "memory" }; } const redis = getCacheRedis(); if (redis && redis.status === "ready") { try { const hit = await redis.get(PREFIX + key); if (hit) { metrics.cacheHit("redis"); memSet(key, hit, Math.min(ttlSec, 30)); return { value: JSON.parse(hit) as T, layer: "redis" }; } } catch { /* redis unavailable: fall through */ } } metrics.cacheMiss(); const value = await compute(); const body = JSON.stringify(value); memSet(key, body, ttlSec); if (redis && redis.status === "ready") redis.set(PREFIX + key, body, "EX", ttlSec).catch(() => {}); return { value, layer: "miss" }; } /** Drop memory entries whose key starts with `prefix` (and best-effort Redis keys). */ export async function invalidate(prefix: string): Promise { let n = 0; for (const k of [...lru.keys()]) if (k.startsWith(prefix)) { lru.delete(k); n++; } const redis = getCacheRedis(); if (redis && redis.status === "ready") { try { let cursor = "0"; do { const [next, keys] = await redis.scan(cursor, "MATCH", `${PREFIX}${prefix}*`, "COUNT", 200); cursor = next; if (keys.length) { await redis.del(...keys); n += keys.length; } } while (cursor !== "0"); } catch { /* ignore */ } } return n; } export function cacheStats(): { entries: number; max: number } { return { entries: lru.size, max: MAX_ENTRIES }; } export function clearMemoryCache(): void { lru.clear(); }