Skip to content

Commit 3ab08e6

Browse files
committed
perf(cache): add active GC sweep and maxSize eviction to TTLCache
1 parent 7bb40d1 commit 3ab08e6

2 files changed

Lines changed: 150 additions & 0 deletions

File tree

lib/cache.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// lib/cache.test.ts
2+
import { describe, it, expect, vi, afterEach } from 'vitest';
3+
import { TTLCache } from './cache';
4+
5+
describe('TTLCache', () => {
6+
afterEach(() => {
7+
vi.useRealTimers();
8+
});
9+
10+
describe('basic get/set', () => {
11+
it('returns null for a missing key', () => {
12+
const cache = new TTLCache<string>();
13+
expect(cache.get('missing')).toBeNull();
14+
cache.destroy();
15+
});
16+
17+
it('returns the value for a live key', () => {
18+
const cache = new TTLCache<string>();
19+
cache.set('user', 'octocat', 60_000);
20+
expect(cache.get('user')).toBe('octocat');
21+
cache.destroy();
22+
});
23+
24+
it('returns null and evicts a key whose TTL has expired', () => {
25+
vi.useFakeTimers();
26+
const cache = new TTLCache<string>();
27+
cache.set('user', 'octocat', 1_000);
28+
vi.advanceTimersByTime(2_000);
29+
expect(cache.get('user')).toBeNull();
30+
cache.destroy();
31+
});
32+
});
33+
34+
describe('clear()', () => {
35+
it('removes all entries', () => {
36+
const cache = new TTLCache<number>();
37+
cache.set('a', 1, 60_000);
38+
cache.set('b', 2, 60_000);
39+
cache.clear();
40+
expect(cache.get('a')).toBeNull();
41+
expect(cache.get('b')).toBeNull();
42+
cache.destroy();
43+
});
44+
});
45+
46+
describe('capacity eviction (maxSize)', () => {
47+
it('does not exceed maxSize — evicts the oldest key on overflow', () => {
48+
const cache = new TTLCache<number>(3);
49+
cache.set('a', 1, 60_000);
50+
cache.set('b', 2, 60_000);
51+
cache.set('c', 3, 60_000);
52+
// Adding a 4th key should evict the oldest ('a')
53+
cache.set('d', 4, 60_000);
54+
expect(cache.get('a')).toBeNull(); // evicted
55+
expect(cache.get('b')).toBe(2);
56+
expect(cache.get('c')).toBe(3);
57+
expect(cache.get('d')).toBe(4);
58+
cache.destroy();
59+
});
60+
61+
it('updating an existing key does not trigger eviction', () => {
62+
const cache = new TTLCache<number>(2);
63+
cache.set('a', 1, 60_000);
64+
cache.set('b', 2, 60_000);
65+
// Updating 'a' should NOT evict 'b' since size stays <= maxSize
66+
cache.set('a', 99, 60_000);
67+
expect(cache.get('a')).toBe(99);
68+
expect(cache.get('b')).toBe(2);
69+
cache.destroy();
70+
});
71+
});
72+
73+
describe('sweep() — active garbage collection', () => {
74+
it('proactively removes expired keys on the next sweep interval', () => {
75+
vi.useFakeTimers();
76+
// 60s sweep interval (default)
77+
const cache = new TTLCache<string>(1000, 60_000);
78+
cache.set('stale', 'data', 1_000); // expires in 1s
79+
// Advance past TTL but before sweep
80+
vi.advanceTimersByTime(5_000);
81+
// Advance past the sweep interval
82+
vi.advanceTimersByTime(60_000);
83+
// The key is gone even without a get() call
84+
expect(cache.get('stale')).toBeNull();
85+
cache.destroy();
86+
});
87+
});
88+
89+
describe('destroy()', () => {
90+
it('clears the store and stops the interval', () => {
91+
vi.useFakeTimers();
92+
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
93+
const cache = new TTLCache<string>();
94+
cache.set('x', 'y', 60_000);
95+
cache.destroy();
96+
expect(cache.get('x')).toBeNull();
97+
expect(clearIntervalSpy).toHaveBeenCalled();
98+
clearIntervalSpy.mockRestore();
99+
});
100+
});
101+
});

lib/cache.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@ type CacheItem<T> = {
55

66
export class TTLCache<T> {
77
private store = new Map<string, CacheItem<T>>();
8+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
9+
private readonly maxSize: number;
10+
11+
constructor(maxSize: number = 1000, cleanupIntervalMs: number = 60000) {
12+
this.maxSize = Math.max(1, maxSize);
13+
const interval = Math.max(1000, cleanupIntervalMs);
14+
15+
// Only run cleanup if we are in an environment that supports setInterval
16+
if (typeof setInterval !== 'undefined') {
17+
const timer = setInterval(() => this.sweep(), interval);
18+
19+
// Unref the timer so it doesn't prevent Node.js from exiting during tests or teardown
20+
const nodeTimer = timer as unknown as { unref?: () => void };
21+
if (nodeTimer && typeof nodeTimer.unref === 'function') {
22+
nodeTimer.unref();
23+
}
24+
25+
this.cleanupInterval = timer;
26+
}
27+
}
28+
29+
private sweep(): void {
30+
const now = Date.now();
31+
for (const [key, item] of this.store.entries()) {
32+
if (now > item.expiresAt) {
33+
this.store.delete(key);
34+
}
35+
}
36+
}
837

938
get(key: string): T | null {
1039
const hit = this.store.get(key);
@@ -19,10 +48,30 @@ export class TTLCache<T> {
1948
}
2049

2150
set(key: string, value: T, ttlMs: number): void {
51+
// Capacity eviction (FIFO / LRU-lite)
52+
if (this.store.size >= this.maxSize && !this.store.has(key)) {
53+
this.sweep(); // Remove expired entries first to free up capacity
54+
if (this.store.size >= this.maxSize) {
55+
// Find the oldest item (first inserted) and remove it
56+
const oldestKey = this.store.keys().next().value;
57+
if (oldestKey !== undefined) {
58+
this.store.delete(oldestKey);
59+
}
60+
}
61+
}
62+
2263
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
2364
}
2465

2566
clear(): void {
2667
this.store.clear();
2768
}
69+
70+
destroy(): void {
71+
if (this.cleanupInterval) {
72+
clearInterval(this.cleanupInterval);
73+
this.cleanupInterval = null;
74+
}
75+
this.clear();
76+
}
2877
}

0 commit comments

Comments
 (0)