/** * MetadataCache - TTL + simple LRU cache * * Features: * - TTL-based expiration * - Simple FIFO eviction when maxItems exceeded * - read-through helper for async producers * - Pseudo-LRU via Map re-insertion */ export class MetadataCache { constructor({ ttlMs = 5 * 60 * 1000, maxItems = 10_000 } = {}) { this.ttlMs = ttlMs; this.maxItems = maxItems; this.store = new Map(); // key -> { value, exp } this.stats = { hits: 0, misses: 0, evictions: 0, sets: 0 }; } _now() { return Date.now(); } /** * Evict oldest entries if cache exceeds maxItems */ _evictIfNeeded() { if (this.store.size <= this.maxItems) return; const toEvict = this.store.size - this.maxItems + Math.floor(this.maxItems * 0.1); let evicted = 0; for (const k of this.store.keys()) { this.store.delete(k); evicted++; if (evicted >= toEvict) break; } this.stats.evictions += evicted; } /** * Get value from cache * Returns { hit: boolean, value: any } */ get(key) { const entry = this.store.get(key); if (!entry) { this.stats.misses++; return { hit: false, value: undefined }; } // Check expiration if (entry.exp < this._now()) { this.store.delete(key); this.stats.misses++; return { hit: false, value: undefined }; } // Touch for pseudo-LRU: re-insert at end this.store.delete(key); this.store.set(key, entry); this.stats.hits++; return { hit: true, value: entry.value }; } /** * Set value in cache with optional TTL override */ set(key, value, { ttlMs = this.ttlMs } = {}) { const exp = this._now() + ttlMs; this.store.set(key, { value, exp }); this.stats.sets++; this._evictIfNeeded(); } /** * Delete specific key */ delete(key) { this.store.delete(key); } /** * Clear entire cache */ clear() { this.store.clear(); } /** * Read-through helper: get from cache or call producer * Returns { value, hit: boolean } */ async remember(key, producer, { ttlMs = this.ttlMs } = {}) { const { hit, value } = this.get(key); if (hit) return { value, hit: true }; const fresh = await producer(); this.set(key, fresh, { ttlMs }); return { value: fresh, hit: false }; } /** * Get cache statistics */ getStats() { const total = this.stats.hits + this.stats.misses; const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0; return { size: this.store.size, maxItems: this.maxItems, ttlMs: this.ttlMs, hits: this.stats.hits, misses: this.stats.misses, hitRate: Math.round(hitRate * 10) / 10, evictions: this.stats.evictions, sets: this.stats.sets }; } /** * Reset statistics */ resetStats() { this.stats = { hits: 0, misses: 0, evictions: 0, sets: 0 }; } }