diff --git a/src/__tests__/services/api/cache.test.ts b/src/__tests__/services/api/cache.test.ts index e5778480..8a16d840 100644 --- a/src/__tests__/services/api/cache.test.ts +++ b/src/__tests__/services/api/cache.test.ts @@ -1,16 +1,16 @@ -/** - * Tests for the in-memory cache module (cache.ts). - * No mocks — exercises the real implementation. - */ import { clearCache, getCache, invalidateCacheByDataVersion, setCache, + setMaxCacheSize, + getCacheStats, + resetCacheStats, } from '../../../services/api/cache'; beforeEach(() => { clearCache(); + resetCacheStats(); }); describe('invalidateCacheByDataVersion', () => { @@ -41,3 +41,61 @@ describe('invalidateCacheByDataVersion', () => { expect(getCache('key:unversioned')).toBe('data'); }); }); + +describe('LRU cache eviction and stats', () => { + it('evicts least recently used items when limit is reached', () => { + // Set size limit to 500 bytes. + // metadata is ~128 bytes. Key 'k1' is 2 bytes. Value 'v1' is 2 bytes. + // Total size of k1 = 4 + 2*2 + 128 = 136 bytes. + // 3 entries will fit (3 * 136 = 408 bytes), but 4 entries (544 bytes) will exceed 500 bytes. + setMaxCacheSize(500); + + setCache('k1', 'val1', 60_000, 300_000); + setCache('k2', 'val2', 60_000, 300_000); + setCache('k3', 'val3', 60_000, 300_000); + + // Verify all 3 exist + expect(getCache('k1')).toBe('val1'); + expect(getCache('k2')).toBe('val2'); + expect(getCache('k3')).toBe('val3'); + + // Add 4th item, k1 should be evicted since it's the oldest (though we accessed it, + // getCache('k1') moved it to the end! So the order from oldest to newest after the reads: + // k2 (oldest), k3, k1 (newest). + // So k2 should be evicted! + setCache('k4', 'val4', 60_000, 300_000); + + expect(getCache('k2')).toBeNull(); + expect(getCache('k1')).toBe('val1'); + expect(getCache('k3')).toBe('val3'); + expect(getCache('k4')).toBe('val4'); + }); + + it('correctly tracks hits and misses stats', () => { + resetCacheStats(); + + // Miss + expect(getCache('non_existent')).toBeNull(); + let stats = getCacheStats(); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(0); + + // Hit + setCache('k1', 'val1', 60_000, 300_000); + expect(getCache('k1')).toBe('val1'); + + stats = getCacheStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(0.5); + + // Reset stats + resetCacheStats(); + stats = getCacheStats(); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(0); + expect(stats.hitRate).toBe(0); + }); +}); + diff --git a/src/services/api/cache.ts b/src/services/api/cache.ts index 4766a1aa..2ee35d00 100644 --- a/src/services/api/cache.ts +++ b/src/services/api/cache.ts @@ -4,9 +4,95 @@ interface CacheEntry { ttl: number; // ms until stale staleTtl: number; // ms until evicted (stale-while-revalidate window) dataVersion?: string; // optional server data version tag + sizeBytes: number; // calculated size of this entry } const store = new Map>(); +let currentCacheSize = 0; +let maxCacheSizeBytes = 100 * 1024 * 1024; // 100MB default + +let cacheHits = 0; +let cacheMisses = 0; + +export function setMaxCacheSize(sizeBytes: number): void { + maxCacheSizeBytes = sizeBytes; + evictToLimit(); +} + +export function getCacheStats() { + const total = cacheHits + cacheMisses; + const hitRate = total === 0 ? 0 : cacheHits / total; + return { + hits: cacheHits, + misses: cacheMisses, + hitRate, + sizeBytes: currentCacheSize, + entryCount: store.size, + }; +} + +export function resetCacheStats(): void { + cacheHits = 0; + cacheMisses = 0; +} + +export function estimateSize(obj: any, visited = new Set()): number { + if (obj === null || obj === undefined) return 0; + + const objType = typeof obj; + if (objType === 'number') return 8; + if (objType === 'string') return obj.length * 2; + if (objType === 'boolean') return 4; + if (objType === 'symbol') return 8; + + if (objType === 'object') { + if (visited.has(obj)) return 0; + visited.add(obj); + + let bytes = 0; + const objClass = Object.prototype.toString.call(obj).slice(8, -1); + + if (objClass === 'Array') { + for (let i = 0; i < obj.length; i++) { + bytes += estimateSize(obj[i], visited); + } + } else if (objClass === 'Object') { + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + bytes += key.length * 2; + bytes += estimateSize(obj[key], visited); + } + } + } else if (objClass === 'Date') { + bytes += 8; + } else { + try { + bytes += JSON.stringify(obj).length * 2; + } catch { + bytes += 100; + } + } + visited.delete(obj); + return bytes; + } + + return 0; +} + +function evictToLimit(): void { + while (currentCacheSize > maxCacheSizeBytes && store.size > 0) { + const oldestKey = store.keys().next().value; + if (oldestKey !== undefined) { + const entry = store.get(oldestKey); + if (entry) { + currentCacheSize -= entry.sizeBytes; + } + store.delete(oldestKey); + } else { + break; + } + } +} function isStale(entry: CacheEntry): boolean { return Date.now() - entry.cachedAt > entry.ttl; @@ -18,7 +104,23 @@ function isExpired(entry: CacheEntry): boolean { export function getCache(key: string): T | null { const entry = store.get(key) as CacheEntry | undefined; - if (!entry || isExpired(entry)) return null; + if (!entry) { + cacheMisses++; + return null; + } + if (isExpired(entry)) { + cacheMisses++; + currentCacheSize -= entry.sizeBytes; + store.delete(key); + return null; + } + + cacheHits++; + + // LRU behavior: move to the end of the Map + store.delete(key); + store.set(key, entry as CacheEntry); + return entry.data; } @@ -35,11 +137,36 @@ export function setCache( staleTtl: number, dataVersion?: string, ): void { - store.set(key, { data, cachedAt: Date.now(), ttl, staleTtl, dataVersion }); + const existing = store.get(key); + if (existing) { + currentCacheSize -= existing.sizeBytes; + store.delete(key); + } + + const dataSize = estimateSize(data); + const sizeBytes = dataSize + (key.length * 2) + 128; // approx 128 bytes metadata overhead + + const entry: CacheEntry = { + data, + cachedAt: Date.now(), + ttl, + staleTtl, + dataVersion, + sizeBytes, + }; + + store.set(key, entry as CacheEntry); + currentCacheSize += sizeBytes; + + evictToLimit(); } export function invalidateCache(key: string): void { - store.delete(key); + const entry = store.get(key); + if (entry) { + currentCacheSize -= entry.sizeBytes; + store.delete(key); + } } /** @@ -50,6 +177,7 @@ export function invalidateCache(key: string): void { export function invalidateCacheByDataVersion(version: string): void { for (const [key, entry] of store) { if (entry.dataVersion === version) { + currentCacheSize -= entry.sizeBytes; store.delete(key); } } @@ -57,6 +185,7 @@ export function invalidateCacheByDataVersion(version: string): void { export function clearCache(): void { store.clear(); + currentCacheSize = 0; } /** @@ -96,3 +225,4 @@ export async function fetchWithSWR( setCache(key, fresh, ttl, staleTtl, dataVersion); return fresh; } +