/** * Search Cache — in-memory TTL cache with request coalescing * * Bounded at MAX_CACHE_ENTRIES to prevent OOM. * Request coalescing deduplicates concurrent identical queries * to prevent cache stampede (critical for agentic tools). */ import { createHash } from "crypto"; const MAX_CACHE_ENTRIES = 500; const DEFAULT_TTL_MS = parseInt(process.env.SEARCH_CACHE_TTL_MS || String(60 * 1000), 10); interface CacheEntry { data: T; expiresAt: number; } const cache = new Map>(); const inflight = new Map>(); let hits = 0; let misses = 0; /** * Normalize a query for cache key computation. * NFKC normalization, lowercase, trim, collapse whitespace. */ function normalizeQuery(query: string): string { return query.normalize("NFKC").toLowerCase().trim().replace(/\s+/g, " "); } /** * Compute a deterministic cache key from search parameters. */ export function computeCacheKey( query: string, provider: string, searchType: string, maxResults: number, country?: string, language?: string, filters?: unknown ): string { const normalized = normalizeQuery(query); const payload = JSON.stringify({ q: normalized, p: provider, t: searchType, n: maxResults, c: country || null, l: language || null, f: filters || null, }); return createHash("sha256").update(payload).digest("hex"); } /** * Evict expired entries and enforce size bound. * Called lazily on writes. O(n) worst case but amortized O(1). */ function evictIfNeeded(): void { const now = Date.now(); // Remove expired entries first for (const [key, entry] of cache) { if (entry.expiresAt <= now) { cache.delete(key); } } // FIFO eviction if still over limit while (cache.size >= MAX_CACHE_ENTRIES) { const firstKey = cache.keys().next().value; if (firstKey !== undefined) { cache.delete(firstKey); } else { break; } } } /** * Get or coalesce: return cached data, join an inflight request, * or execute the fetch function and cache the result. * * @param key - Cache key from computeCacheKey() * @param ttlMs - TTL in milliseconds (0 to bypass cache AND coalescing) * @param fetchFn - Function to execute on cache miss * @returns The cached or freshly fetched data */ export async function getOrCoalesce( key: string, ttlMs: number, fetchFn: () => Promise ): Promise<{ data: T; cached: boolean }> { // When ttlMs === 0 the caller explicitly wants to bypass the cache. // Skip both the cache lookup AND the inflight-coalescing step so every // concurrent call gets its own independent upstream fetch. Without this // guard, ttlMs=0 callers still get coalesced results and receive // { cached: true } even though caching was explicitly disabled. if (ttlMs <= 0) { misses++; const data = await fetchFn(); return { data, cached: false }; } // 1. Check cache const cached = cache.get(key) as CacheEntry | undefined; if (cached && cached.expiresAt > Date.now()) { hits++; return { data: cached.data, cached: true }; } // 2. Join inflight request if one exists (request coalescing) const existing = inflight.get(key) as Promise | undefined; if (existing) { hits++; const data = await existing; return { data, cached: true }; } // 3. Cache miss — execute fetch misses++; const promise = fetchFn(); inflight.set(key, promise); try { const data = await promise; evictIfNeeded(); cache.set(key, { data, expiresAt: Date.now() + ttlMs }); return { data, cached: false }; } finally { inflight.delete(key); } } /** * Get cache statistics for monitoring. */ export function getCacheStats(): { size: number; hits: number; misses: number } { return { size: cache.size, hits, misses }; } /** * Default TTL for search cache entries. */ export const SEARCH_CACHE_DEFAULT_TTL_MS = DEFAULT_TTL_MS;