import { createLogger } from '../utils/logger.js'; const logger = createLogger('CACHE'); class CacheService { constructor() { this.cache = new Map(); this.stats = { prs: { hits: 0, misses: 0, sets: 0, expirations: 0 }, candidate: { hits: 0, misses: 0, sets: 0, expirations: 0 }, geojson: { hits: 0, misses: 0, sets: 0, expirations: 0 }, image: { hits: 0, misses: 0, sets: 0, expirations: 0 }, poll: { hits: 0, misses: 0, sets: 0, expirations: 0 } }; this.logger = logger; logger.info('INIT', 'Cache service initialized'); } getCacheKey(type, key) { return `${type}:${key}`; } getTTL(type) { const ttls = { image: 24 * 60 * 60 * 1000, prs: 60 * 60 * 1000, candidate: 60 * 60 * 1000, geojson: 24 * 60 * 60 * 1000, poll: 5 * 60 * 1000 }; return ttls[type] || 60 * 60 * 1000; } set(type, key, value, ttl = null) { const cacheKey = this.getCacheKey(type, key); const expiresAt = Date.now() + (ttl || this.getTTL(type)); if (!this.stats[type]) { this.stats[type] = { hits: 0, misses: 0, sets: 0, expirations: 0 }; } this.cache.set(cacheKey, { data: value, expiresAt, createdAt: Date.now(), type }); this.stats[type].sets++; if (type === 'image') { this.logger.info('SET', `Cached image: ${key}`, { size: value?.buffer ? `${(value.buffer.length / 1024).toFixed(2)} KB` : 'unknown', contentType: value?.contentType || 'unknown', expiresIn: `${Math.round((ttl || this.getTTL(type)) / 1000 / 60)} minutes` }); } else { this.logger.info('SET', `Cached ${type}: ${key}`); } return true; } get(type, key) { const cacheKey = this.getCacheKey(type, key); const entry = this.cache.get(cacheKey); if (!this.stats[type]) { this.stats[type] = { hits: 0, misses: 0, sets: 0, expirations: 0 }; } if (!entry) { this.stats[type].misses++; this.logger.info('MISS', `Cache miss: ${type}:${key}`); return null; } if (Date.now() > entry.expiresAt) { this.cache.delete(cacheKey); this.stats[type].misses++; this.stats[type].expirations++; this.logger.info('EXPIRED', `Cache expired: ${type}:${key}`); return null; } this.stats[type].hits++; if (type === 'image') { this.logger.success('HIT', `Cache hit: ${type}:${key}`, { age: `${Math.round((Date.now() - entry.createdAt) / 1000 / 60)} minutes`, size: entry.data?.buffer ? `${(entry.data.buffer.length / 1024).toFixed(2)} KB` : 'unknown' }); } else { this.logger.success('HIT', `Cache hit: ${type}:${key}`); } return entry.data; } has(type, key) { const cacheKey = this.getCacheKey(type, key); const entry = this.cache.get(cacheKey); if (!entry) return false; if (Date.now() > entry.expiresAt) { this.cache.delete(cacheKey); return false; } return true; } delete(type, key) { const cacheKey = this.getCacheKey(type, key); const deleted = this.cache.delete(cacheKey); if (deleted) { this.logger.info('DELETE', `Deleted cache: ${type}:${key}`); } return deleted; } flush(type = null) { if (type) { let deleted = 0; for (const [key, entry] of this.cache.entries()) { if (entry.type === type) { this.cache.delete(key); deleted++; } } if (this.stats[type]) { this.stats[type] = { hits: 0, misses: 0, sets: 0, expirations: 0 }; } this.logger.info('FLUSH', `Flushed cache type: ${type}`, { deleted }); } else { const size = this.cache.size; this.cache.clear(); Object.keys(this.stats).forEach(key => { this.stats[key] = { hits: 0, misses: 0, sets: 0, expirations: 0 }; }); this.logger.info('FLUSH', `Flushed all cache`, { deleted: size }); } } cleanup() { const now = Date.now(); let cleaned = 0; for (const [key, entry] of this.cache.entries()) { if (now > entry.expiresAt) { this.cache.delete(key); cleaned++; if (this.stats[entry.type]) { this.stats[entry.type].expirations++; } } } if (cleaned > 0) { this.logger.info('CLEANUP', `Cleaned ${cleaned} expired entries`, { remaining: this.cache.size }); } return cleaned; } getStats() { const typeStats = {}; for (const type in this.stats) { const stat = this.stats[type]; const total = stat.hits + stat.misses; const hitRate = total > 0 ? ((stat.hits / total) * 100).toFixed(2) : '0.00'; typeStats[type] = { ...stat, hitRate: `${hitRate}%` }; } return { total: this.cache.size, types: typeStats, memory: this._estimateMemoryUsage() }; } _estimateMemoryUsage() { let totalBytes = 0; for (const [key, entry] of this.cache.entries()) { totalBytes += key.length * 2; if (entry.data?.buffer && Buffer.isBuffer(entry.data.buffer)) { totalBytes += entry.data.buffer.length; } else if (typeof entry.data === 'string') { totalBytes += entry.data.length * 2; } else if (entry.data) { totalBytes += JSON.stringify(entry.data).length * 2; } } return { bytes: totalBytes, kb: (totalBytes / 1024).toFixed(2), mb: (totalBytes / 1024 / 1024).toFixed(2) }; } } const cacheService = new CacheService(); setInterval(() => { cacheService.cleanup(); }, 5 * 60 * 1000); export default cacheService;