Spaces:
Running
Running
| 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; | |