/** * In-Memory Cache Middleware * Caches API responses to reduce database load */ const logger = require("../config/logger"); class CacheManager { constructor(defaultTTL = 300000, maxSize = 2000) { // 5 minutes default, max 2000 entries (optimized for performance) this.cache = new Map(); this.defaultTTL = defaultTTL; this.maxSize = maxSize; this.stats = { hits: 0, misses: 0, evictions: 0 }; // Use Map for O(1) LRU tracking instead of array indexOf/splice this.lruHead = null; // Most recently used this.lruTail = null; // Least recently used this.lruNodes = new Map(); // key -> {prev, next, key} } set(key, value, ttl = this.defaultTTL) { const expiresAt = Date.now() + ttl; // If key exists, remove from LRU list first if (this.cache.has(key)) { this._removeLRUNode(key); } else if (this.cache.size >= this.maxSize) { // Evict least recently used if (this.lruTail) { const evictKey = this.lruTail.key; this.cache.delete(evictKey); this._removeLRUNode(evictKey); this.stats.evictions++; logger.debug(`Cache LRU eviction: ${evictKey}`); } } this.cache.set(key, { value, expiresAt }); this._addLRUNode(key); // Add to head (most recent) logger.debug(`Cache set: ${key} (TTL: ${ttl}ms)`); } get(key) { const cached = this.cache.get(key); if (!cached) { this.stats.misses++; logger.debug(`Cache miss: ${key}`); return null; } const now = Date.now(); if (now > cached.expiresAt) { this.cache.delete(key); this._removeLRUNode(key); this.stats.misses++; logger.debug(`Cache expired: ${key}`); return null; } // Move to head (most recently used) - O(1) this._removeLRUNode(key); this._addLRUNode(key); this.stats.hits++; logger.debug(`Cache hit: ${key}`); return cached.value; } delete(key) { const deleted = this.cache.delete(key); if (deleted) logger.debug(`Cache invalidated: ${key}`); return deleted; } deletePattern(pattern) { let count = 0; for (const key of this.cache.keys()) { if (key.includes(pattern)) { this.cache.delete(key); this._removeLRUNode(key); count++; } } if (count > 0) logger.debug(`Cache pattern invalidated: ${pattern} (${count} keys)`); return count; } clear() { const size = this.cache.size; this.cache.clear(); this.lruNodes.clear(); this.lruHead = null; this.lruTail = null; logger.info(`Cache cleared (${size} keys)`); } size() { return this.cache.size; } // Get cache statistics getStats() { const hitRate = this.stats.hits + this.stats.misses > 0 ? ( (this.stats.hits / (this.stats.hits + this.stats.misses)) * 100 ).toFixed(2) : 0; return { ...this.stats, hitRate: `${hitRate}%`, size: this.cache.size, maxSize: this.maxSize, }; } // Reset statistics resetStats() { this.stats = { hits: 0, misses: 0, evictions: 0 }; } // O(1) LRU operations using doubly-linked list pattern _addLRUNode(key) { const node = { key, prev: null, next: this.lruHead }; if (this.lruHead) { this.lruHead.prev = node; } this.lruHead = node; if (!this.lruTail) { this.lruTail = node; } this.lruNodes.set(key, node); } _removeLRUNode(key) { const node = this.lruNodes.get(key); if (!node) return; if (node.prev) { node.prev.next = node.next; } else { this.lruHead = node.next; } if (node.next) { node.next.prev = node.prev; } else { this.lruTail = node.prev; } this.lruNodes.delete(key); } // Clean up expired entries cleanup() { const now = Date.now(); let cleaned = 0; for (const [key, { expiresAt }] of this.cache.entries()) { if (now > expiresAt) { this.cache.delete(key); cleaned++; } } if (cleaned > 0) logger.debug(`Cache cleanup: removed ${cleaned} expired entries`); return cleaned; } } // Global cache instance const cache = new CacheManager(); // Cleanup interval reference (for graceful shutdown) let cleanupInterval = null; // Start automatic cleanup (optional, call from server startup) const startCleanup = () => { if (!cleanupInterval) { cleanupInterval = setInterval(() => cache.cleanup(), 300000); // 5 minutes logger.debug("Cache cleanup scheduler started"); } }; // Stop automatic cleanup (for graceful shutdown) const stopCleanup = () => { if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = null; logger.debug("Cache cleanup scheduler stopped"); } }; /** * Cache middleware factory * @param {number} ttl - Time to live in milliseconds * @param {function} keyGenerator - Function to generate cache key from req */ const cacheMiddleware = (ttl = 300000, keyGenerator = null) => { return (req, res, next) => { // Skip cache for authenticated requests if (req.session && req.session.userId) { return next(); } const key = keyGenerator ? keyGenerator(req) : `${req.method}:${req.originalUrl}`; const cachedResponse = cache.get(key); if (cachedResponse) { res.setHeader("X-Cache", "HIT"); return res.json(cachedResponse); } // Store original json method const originalJson = res.json.bind(res); // Override json method to cache response res.json = function (data) { cache.set(key, data, ttl); res.setHeader("X-Cache", "MISS"); return originalJson(data); }; next(); }; }; module.exports = { cache, cacheMiddleware, startCleanup, stopCleanup, };