Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions src/__tests__/services/api/cache.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});

136 changes: 133 additions & 3 deletions src/services/api/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,95 @@ interface CacheEntry<T> {
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<string, CacheEntry<unknown>>();
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<any>()): 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<T>(entry: CacheEntry<T>): boolean {
return Date.now() - entry.cachedAt > entry.ttl;
Expand All @@ -18,7 +104,23 @@ function isExpired<T>(entry: CacheEntry<T>): boolean {

export function getCache<T>(key: string): T | null {
const entry = store.get(key) as CacheEntry<T> | 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<unknown>);

return entry.data;
}

Expand All @@ -35,11 +137,36 @@ export function setCache<T>(
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<T> = {
data,
cachedAt: Date.now(),
ttl,
staleTtl,
dataVersion,
sizeBytes,
};

store.set(key, entry as CacheEntry<unknown>);
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);
}
}

/**
Expand All @@ -50,13 +177,15 @@ 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);
}
}
}

export function clearCache(): void {
store.clear();
currentCacheSize = 0;
}

/**
Expand Down Expand Up @@ -96,3 +225,4 @@ export async function fetchWithSWR<T>(
setCache(key, fresh, ttl, staleTtl, dataVersion);
return fresh;
}

Loading