redThread / server /src /services /cache.service.js
3v324v23's picture
Initial commit of RedThread project
0dd2082
const logger = require('../utils/logger');
/**
* In-memory TTL Cache
*
* Interview note: This is a hash-map cache with lazy expiration.
* Keys are hashed intent signatures. Values expire after TTL.
*
* Why not Redis? At this scale (single server, <10K users), in-memory
* is simpler, zero-latency, and zero-dependency. Redis makes sense when
* you need persistence across restarts or shared cache across instances.
*/
class CacheService {
constructor(options = {}) {
this.store = new Map();
this.defaultTTL = options.ttl || 5 * 60 * 1000; // 5 minutes
this.maxSize = options.maxSize || 500;
this.hits = 0;
this.misses = 0;
this.evictions = 0;
// Periodic cleanup every 60s
this._cleanupInterval = setInterval(() => this._cleanup(), 60000);
}
/**
* Generate a deterministic cache key from an intent object.
*/
_makeKey(intent) {
const normalized = {
category: intent.category || 'all',
location: (intent.location || '').toLowerCase().trim(),
budget: intent.budget?.max || '',
features: (intent.features || []).sort().join(','),
sortBy: intent.sortBy || 'relevance',
};
return JSON.stringify(normalized);
}
get(intent) {
const key = this._makeKey(intent);
const entry = this.store.get(key);
if (!entry) {
this.misses++;
return null;
}
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
this.misses++;
return null;
}
this.hits++;
logger.info('Cache HIT', { key: key.substring(0, 60) });
return entry.value;
}
set(intent, value, ttl) {
const key = this._makeKey(intent);
if (this.store.size >= this.maxSize) {
this._evictOldest();
}
this.store.set(key, {
value,
createdAt: Date.now(),
expiresAt: Date.now() + (ttl || this.defaultTTL),
});
}
_evictOldest() {
let oldestKey = null;
let oldestTime = Infinity;
for (const [key, entry] of this.store) {
if (entry.createdAt < oldestTime) {
oldestTime = entry.createdAt;
oldestKey = key;
}
}
if (oldestKey) {
this.store.delete(oldestKey);
this.evictions++;
}
}
_cleanup() {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of this.store) {
if (now > entry.expiresAt) {
this.store.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
logger.info('Cache cleanup', { cleaned, remaining: this.store.size });
}
}
invalidate(intent) {
const key = this._makeKey(intent);
return this.store.delete(key);
}
clear() {
this.store.clear();
this.hits = 0;
this.misses = 0;
this.evictions = 0;
}
getStats() {
const total = this.hits + this.misses;
return {
size: this.store.size,
maxSize: this.maxSize,
hits: this.hits,
misses: this.misses,
evictions: this.evictions,
hitRate: total > 0 ? ((this.hits / total) * 100).toFixed(1) + '%' : '0%',
ttl: this.defaultTTL,
};
}
destroy() {
clearInterval(this._cleanupInterval);
this.store.clear();
}
}
// Singleton
const cache = new CacheService();
module.exports = cache;