import "server-only"; import { redis } from "@/lib/redis"; type CacheEntry = { data: T; expiresAt: number }; const memory = new Map>(); // Cap the in-process map so dynamic keys (leaderboard currencies, article // slugs, …) can never grow it without bound. Oldest entries are evicted. const MAX_MEMORY_ENTRIES = 500; // Single-flight: a key being (re)computed is awaited by concurrent callers // instead of each starting its own `fn()` (cache-stampede protection). const inFlight = new Map>(); /** Drop a key from the in-process cache (used when an upstream value changes). */ export function invalidateMemory(key: string): void { memory.delete(key); inFlight.delete(key); } function pruneExpired(now: number): void { for (const [key, entry] of memory) { if (entry.expiresAt <= now) memory.delete(key); } } function setMemory(key: string, entry: CacheEntry): void { if (memory.has(key)) { memory.set(key, entry); return; } if (memory.size >= MAX_MEMORY_ENTRIES) { // Drop expired entries first, then evict the oldest (insertion order). pruneExpired(Date.now()); if (memory.size >= MAX_MEMORY_ENTRIES) { const oldest = memory.keys().next().value; if (oldest !== undefined) memory.delete(oldest); } } memory.set(key, entry); } /** * Redis-first cached query with an in-memory fallback. * Use for read-heavy endpoints polled by the browser (online count, etc.). */ export async function cached( key: string, ttlMs: number, fn: () => Promise, ): Promise { const ttlSec = Math.ceil(ttlMs / 1000); // In-memory fast path — served first so repeated reads within a TTL window // don't each pay a Redis round-trip (Redis is still the shared fallback). // `existing &&` short-circuits so Date.now() is never evaluated during // prerender when the map is empty (keeps `next build` prerendering clean). const existing = memory.get(key); if (existing && existing.expiresAt > Date.now()) { return existing.data as T; } // Single-flight: a concurrent request already recomputing this key. const pending = inFlight.get(key); if (pending) return (await pending) as T; const compute = (async (): Promise => { // Redis path (shared across instances). if (redis && redis.status !== "end") { try { const cached = await redis.get(key); if (cached !== null && cached !== undefined) { const data = JSON.parse(cached) as T; setMemory(key, { data, expiresAt: Date.now() + ttlMs }); return data; } } catch { /* fall through to fn */ } } const data = await fn(); if (redis && redis.status !== "end") { try { await redis.setex(key, ttlSec, JSON.stringify(data)); } catch { /* non-critical: memory cache still works */ } } setMemory(key, { data, expiresAt: Date.now() + ttlMs }); return data; })().finally(() => { inFlight.delete(key); }); inFlight.set(key, compute); return compute; }