SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
3 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
2.9 KB · 77 lines typescript
Raw Blame History
1/**2 * Two-level response cache: in-process LRU (fast path) + Redis (shared between API replicas).3 * Key = route + normalized query. Invalidation is TTL-based only; admin writes bust the memory layer.4 */5import { getCacheRedis } from "./redis.js";6import { getEnv } from "./env.js";7import { metrics } from "./metrics.js";89const PREFIX = "dci:api:cache:";10const MAX_ENTRIES = Number(process.env.DCI_API_CACHE_ENTRIES ?? 800);1112interface Entry { value: string; expiresAt: number }13const lru = new Map<string, Entry>();1415function memGet(key: string): string | null {16  const e = lru.get(key);17  if (!e) return null;18  if (e.expiresAt <= Date.now()) { lru.delete(key); return null; }19  lru.delete(key); lru.set(key, e); // refresh recency20  return e.value;21}2223function memSet(key: string, value: string, ttlSec: number): void {24  if (lru.size >= MAX_ENTRIES) { const first = lru.keys().next().value; if (first !== undefined) lru.delete(first); }25  lru.set(key, { value, expiresAt: Date.now() + ttlSec * 1000 });26}2728export type CacheLayer = "memory" | "redis" | "miss" | "bypass";2930export interface CacheResult<T> { value: T; layer: CacheLayer }3132/** Compute-or-load. The value is JSON-serialized once and reused for both layers. */33export async function cached<T>(key: string, ttlSec: number, compute: () => Promise<T>): Promise<CacheResult<T>> {34  if (!getEnv().cacheEnabled || ttlSec <= 0) return { value: await compute(), layer: "bypass" };35  const mem = memGet(key);36  if (mem !== null) { metrics.cacheHit("memory"); return { value: JSON.parse(mem) as T, layer: "memory" }; }37  const redis = getCacheRedis();38  if (redis && redis.status === "ready") {39    try {40      const hit = await redis.get(PREFIX + key);41      if (hit) { metrics.cacheHit("redis"); memSet(key, hit, Math.min(ttlSec, 30)); return { value: JSON.parse(hit) as T, layer: "redis" }; }42    } catch { /* redis unavailable: fall through */ }43  }44  metrics.cacheMiss();45  const value = await compute();46  const body = JSON.stringify(value);47  memSet(key, body, ttlSec);48  if (redis && redis.status === "ready") redis.set(PREFIX + key, body, "EX", ttlSec).catch(() => {});49  return { value, layer: "miss" };50}5152/** Drop memory entries whose key starts with `prefix` (and best-effort Redis keys). */53export async function invalidate(prefix: string): Promise<number> {54  let n = 0;55  for (const k of [...lru.keys()]) if (k.startsWith(prefix)) { lru.delete(k); n++; }56  const redis = getCacheRedis();57  if (redis && redis.status === "ready") {58    try {59      let cursor = "0";60      do {61        const [next, keys] = await redis.scan(cursor, "MATCH", `${PREFIX}${prefix}*`, "COUNT", 200);62        cursor = next;63        if (keys.length) { await redis.del(...keys); n += keys.length; }64      } while (cursor !== "0");65    } catch { /* ignore */ }66  }67  return n;68}6970export function cacheStats(): { entries: number; max: number } {71  return { entries: lru.size, max: MAX_ENTRIES };72}7374export function clearMemoryCache(): void {75  lru.clear();76}77