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
84 changes: 60 additions & 24 deletions oracle/src/services/cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Cache Service
*
* In-memory caching layer with TTL support.
* In-memory caching layer with TTL support and LRU eviction.
* Supports Redis too.
*/

Expand All @@ -14,6 +14,8 @@ import { logger } from '../utils/logger.js';
export interface CacheConfig {
defaultTtlSeconds: number;
maxEntries: number;
/** Fraction of entries to evict in a batch when at capacity (0 < x <= 1) */
evictBatchFraction: number;
/** Redis URL (optional) */
redisUrl?: string;
}
Expand All @@ -24,28 +26,36 @@ export interface CacheConfig {
const DEFAULT_CONFIG: CacheConfig = {
defaultTtlSeconds: 30,
maxEntries: 1000,
evictBatchFraction: 0.1,
};

/**
* In-memory cache implementation
* In-memory LRU cache implementation.
*
* Access order is maintained by deleting and re-inserting keys into the Map
* on every read, so the Map's natural insertion order reflects LRU order
* (oldest = first entry, most-recently-used = last entry).
*/
export class Cache {
private config: CacheConfig;
private store: Map<string, CacheEntry<unknown>> = new Map();
private hits: number = 0;
private misses: number = 0;
private evictions: number = 0;

constructor(config: Partial<CacheConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };

logger.info('Cache initialized', {
defaultTtlSeconds: this.config.defaultTtlSeconds,
maxEntries: this.config.maxEntries,
evictBatchFraction: this.config.evictBatchFraction,
});
}

/**
* Get a value from cache
* Get a value from cache.
* Moves the accessed entry to the "most recently used" position.
*/
get<T>(key: string): T | undefined {
const entry = this.store.get(key) as CacheEntry<T> | undefined;
Expand All @@ -62,20 +72,27 @@ export class Cache {
return undefined;
}

// Refresh LRU position: delete then re-insert moves key to end of Map
this.store.delete(key);
this.store.set(key, entry);

this.hits++;
return entry.data;
}

/**
* Set a value in cache with optional TTL
* Set a value in cache with optional TTL.
* Performs LRU batch eviction when at capacity.
*/
set<T>(key: string, value: T, ttlSeconds?: number): void {
const ttl = ttlSeconds ?? this.config.defaultTtlSeconds;
const now = Date.now();

// Evict oldest entries if at capacity
if (this.store.size >= this.config.maxEntries) {
this.evictOldest();
// If key already exists, remove it first so it gets a fresh LRU position
if (this.store.has(key)) {
this.store.delete(key);
} else if (this.store.size >= this.config.maxEntries) {
this.evictLRUBatch();
}

const entry: CacheEntry<T> = {
Expand Down Expand Up @@ -121,45 +138,64 @@ export class Cache {
}

/**
* Get cache statistics
* Get cache statistics including hit rate and eviction count.
*/
getStats(): {
size: number;
hits: number;
misses: number;
hitRate: number;
evictions: number;
} {
const total = this.hits + this.misses;
const hitRate = total > 0 ? this.hits / total : 0;

logger.debug('Cache stats', {
size: this.store.size,
hits: this.hits,
misses: this.misses,
hitRate: hitRate.toFixed(4),
evictions: this.evictions,
});

return {
size: this.store.size,
hits: this.hits,
misses: this.misses,
hitRate: total > 0 ? this.hits / total : 0,
hitRate,
evictions: this.evictions,
};
}

/**
* Evict oldest entries to make room
* Evict a batch of least-recently-used entries.
*
* The Map preserves insertion order and we refresh position on every get,
* so the first N keys are always the least recently used.
* Batch size = ceil(maxEntries * evictBatchFraction), minimum 1.
*/
private evictOldest(): void {
let oldestKey: string | undefined;
let oldestTime = Infinity;

for (const [key, entry] of this.store) {
if (entry.cachedAt < oldestTime) {
oldestTime = entry.cachedAt;
oldestKey = key;
}
private evictLRUBatch(): void {
const batchSize = Math.max(
1,
Math.ceil(this.config.maxEntries * this.config.evictBatchFraction),
);

let evicted = 0;
for (const key of this.store.keys()) {
if (evicted >= batchSize) break;
this.store.delete(key);
evicted++;
}

if (oldestKey) {
this.store.delete(oldestKey);
logger.debug(`Evicted oldest cache entry: ${oldestKey}`);
}
this.evictions += evicted;
logger.debug(`LRU batch eviction: removed ${evicted} entries`, {
remaining: this.store.size,
totalEvictions: this.evictions,
});
}

/**
* Clean up expired entries periodicaly
* Clean up expired entries periodically
*/
cleanup(): number {
const now = Date.now();
Expand Down
131 changes: 126 additions & 5 deletions oracle/tests/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Tests for Cache Service
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';

Check warning on line 5 in oracle/tests/cache.test.ts

View workflow job for this annotation

GitHub Actions / Oracle — Lint, Test, Build (18)

'vi' is defined but never used

Check warning on line 5 in oracle/tests/cache.test.ts

View workflow job for this annotation

GitHub Actions / Oracle — Lint, Test, Build (20)

'vi' is defined but never used
import { Cache, PriceCache, createCache, createPriceCache } from '../src/services/cache.js';

describe('Cache', () => {
Expand Down Expand Up @@ -136,21 +136,136 @@

expect(stats.size).toBe(3);
});

it('should report eviction count', () => {
// maxEntries=3, batch=10% => ceil(0.3)=1 eviction per batch
cache = createCache({ maxEntries: 3, evictBatchFraction: 0.1 });

cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.set('d', 4); // triggers eviction

const stats = cache.getStats();
expect(stats.evictions).toBeGreaterThanOrEqual(1);
});
});

describe('eviction', () => {
it('should evict oldest entry when at capacity', () => {
cache = createCache({ maxEntries: 3 });
describe('LRU eviction', () => {
it('should evict least recently used entry first (single eviction)', () => {
// maxEntries=3, batch fraction=0.1 => ceil(3*0.1)=ceil(0.3)=1 eviction
cache = createCache({ maxEntries: 3, evictBatchFraction: 0.1 });

cache.set('first', 1);
cache.set('second', 2);
cache.set('third', 3);

// Access 'first' to make it recently used — 'second' becomes LRU
cache.get('first');

// Adding 'fourth' should evict 'second' (LRU)
cache.set('fourth', 4);

expect(cache.get('first')).toBeUndefined();
expect(cache.get('second')).toBe(2);
expect(cache.get('second')).toBeUndefined(); // evicted
expect(cache.get('first')).toBe(1);
expect(cache.get('third')).toBe(3);
expect(cache.get('fourth')).toBe(4);
});

it('should evict a batch of LRU entries when at capacity', () => {
// maxEntries=10, batch=10% => ceil(1)=1; use 50% to evict 5
cache = createCache({ maxEntries: 10, evictBatchFraction: 0.5 });

for (let i = 0; i < 10; i++) {
cache.set(`key${i}`, i);
}

// Access keys 5-9 to make them recently used; keys 0-4 are LRU
for (let i = 5; i < 10; i++) {
cache.get(`key${i}`);
}

// Adding one more triggers batch eviction of 5 LRU entries (keys 0-4)
cache.set('new', 99);

for (let i = 0; i < 5; i++) {
expect(cache.get(`key${i}`)).toBeUndefined();
}
for (let i = 5; i < 10; i++) {
expect(cache.get(`key${i}`)).toBe(i);
}
expect(cache.get('new')).toBe(99);
});

it('should evict 10% batch by default when at capacity', () => {
// maxEntries=10, default 10% => ceil(1)=1 eviction per trigger
cache = createCache({ maxEntries: 10, evictBatchFraction: 0.1 });

for (let i = 0; i < 10; i++) {
cache.set(`key${i}`, i);
}

// key0 is LRU; adding one more should evict key0
cache.set('extra', 100);

expect(cache.get('key0')).toBeUndefined();
expect(cache.getStats().evictions).toBe(1);
});

it('should update LRU order when a key is overwritten', () => {
// maxEntries=3, fraction=0.1 => ceil(0.3)=1 eviction per batch
cache = createCache({ maxEntries: 3, evictBatchFraction: 0.1 });

cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);

// Overwrite 'a' — it should move to most-recently-used position
cache.set('a', 10);

// 'b' is now LRU; adding 'd' should evict 'b'
cache.set('d', 4);

expect(cache.get('b')).toBeUndefined();
expect(cache.get('a')).toBe(10);
expect(cache.get('c')).toBe(3);
expect(cache.get('d')).toBe(4);
});
});

describe('eviction under load', () => {
it('should handle rapid insertions without exceeding maxEntries by more than batchSize', () => {
const maxEntries = 100;
const batchFraction = 0.1;
cache = createCache({ maxEntries, evictBatchFraction: batchFraction });

// Insert 200 entries — cache should never grow beyond maxEntries
for (let i = 0; i < 200; i++) {
cache.set(`load-key-${i}`, i);
expect(cache.getStats().size).toBeLessThanOrEqual(maxEntries);
}

const stats = cache.getStats();
expect(stats.evictions).toBeGreaterThan(0);
expect(stats.size).toBeLessThanOrEqual(maxEntries);
});

it('should maintain high hit rate when recently set keys are accessed', () => {
cache = createCache({ maxEntries: 50, evictBatchFraction: 0.1 });

// Fill cache
for (let i = 0; i < 50; i++) {
cache.set(`k${i}`, i);
}

// Access all keys (hits)
for (let i = 0; i < 50; i++) {
cache.get(`k${i}`);
}

const stats = cache.getStats();
expect(stats.hitRate).toBeGreaterThan(0.8);
});
});

describe('cleanup', () => {
Expand Down Expand Up @@ -224,5 +339,11 @@
expect(stats.hits).toBe(1);
expect(stats.misses).toBe(1);
});

it('should include eviction count in stats', () => {
const stats = priceCache.getStats();
expect(stats.evictions).toBeDefined();
expect(stats.evictions).toBeGreaterThanOrEqual(0);
});
});
});
Loading
Loading