2026-01-01 22:24:30 -06:00
|
|
|
/**
|
|
|
|
|
* In-Memory Cache Middleware
|
|
|
|
|
* Caches API responses to reduce database load
|
|
|
|
|
*/
|
|
|
|
|
const logger = require("../config/logger");
|
|
|
|
|
|
|
|
|
|
class CacheManager {
|
2026-01-04 17:52:37 -06:00
|
|
|
constructor(defaultTTL = 300000, maxSize = 2000) {
|
|
|
|
|
// 5 minutes default, max 2000 entries (optimized for performance)
|
2026-01-01 22:24:30 -06:00
|
|
|
this.cache = new Map();
|
|
|
|
|
this.defaultTTL = defaultTTL;
|
2026-01-04 17:52:37 -06:00
|
|
|
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}
|
2026-01-01 22:24:30 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set(key, value, ttl = this.defaultTTL) {
|
|
|
|
|
const expiresAt = Date.now() + ttl;
|
2026-01-04 17:52:37 -06:00
|
|
|
|
|
|
|
|
// 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}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-01 22:24:30 -06:00
|
|
|
this.cache.set(key, { value, expiresAt });
|
2026-01-04 17:52:37 -06:00
|
|
|
this._addLRUNode(key); // Add to head (most recent)
|
2026-01-01 22:24:30 -06:00
|
|
|
logger.debug(`Cache set: ${key} (TTL: ${ttl}ms)`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get(key) {
|
|
|
|
|
const cached = this.cache.get(key);
|
|
|
|
|
|
2026-01-04 17:52:37 -06:00
|
|
|
if (!cached) {
|
|
|
|
|
this.stats.misses++;
|
|
|
|
|
logger.debug(`Cache miss: ${key}`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now > cached.expiresAt) {
|
2026-01-01 22:24:30 -06:00
|
|
|
this.cache.delete(key);
|
2026-01-04 17:52:37 -06:00
|
|
|
this._removeLRUNode(key);
|
|
|
|
|
this.stats.misses++;
|
2026-01-01 22:24:30 -06:00
|
|
|
logger.debug(`Cache expired: ${key}`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-04 17:52:37 -06:00
|
|
|
// Move to head (most recently used) - O(1)
|
|
|
|
|
this._removeLRUNode(key);
|
|
|
|
|
this._addLRUNode(key);
|
|
|
|
|
|
|
|
|
|
this.stats.hits++;
|
2026-01-01 22:24:30 -06:00
|
|
|
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);
|
|
|
|
|
count++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (count > 0)
|
|
|
|
|
logger.debug(`Cache pattern invalidated: ${pattern} (${count} keys)`);
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clear() {
|
|
|
|
|
const size = this.cache.size;
|
|
|
|
|
this.cache.clear();
|
2026-01-04 17:52:37 -06:00
|
|
|
this.lruNodes.clear();
|
|
|
|
|
this.lruHead = null;
|
|
|
|
|
this.lruTail = null;
|
2026-01-01 22:24:30 -06:00
|
|
|
logger.info(`Cache cleared (${size} keys)`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
size() {
|
|
|
|
|
return this.cache.size;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-04 17:52:37 -06:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-01 22:24:30 -06:00
|
|
|
// 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,
|
|
|
|
|
};
|