Spaces:
Sleeping
Sleeping
Sasha commited on
Commit ·
31d49af
1
Parent(s): 963afd2
feat: implement LRU cache eviction policy to prevent memory leaks
Browse files- server/.env.example +6 -0
- server/cache.js +77 -24
server/.env.example
CHANGED
|
@@ -24,3 +24,9 @@ ADMIN_USERNAMES=crimbr1243
|
|
| 24 |
# Security Key for Local Worker
|
| 25 |
# The Local Worker must send this key in the 'x-api-key' header when posting logs
|
| 26 |
API_KEY=a_long_secure_token_for_local_worker_to_server_sync_98765
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# Security Key for Local Worker
|
| 25 |
# The Local Worker must send this key in the 'x-api-key' header when posting logs
|
| 26 |
API_KEY=a_long_secure_token_for_local_worker_to_server_sync_98765
|
| 27 |
+
|
| 28 |
+
# Caching Configuration
|
| 29 |
+
# CACHE_ENABLED=true
|
| 30 |
+
# CACHE_DEFAULT_TTL=15
|
| 31 |
+
# CACHE_MAX_KEYS=1000
|
| 32 |
+
|
server/cache.js
CHANGED
|
@@ -9,6 +9,7 @@ dotenv.config();
|
|
| 9 |
// Configuration flags
|
| 10 |
const IS_CACHE_ENABLED = process.env.CACHE_ENABLED !== 'false';
|
| 11 |
const DEFAULT_TTL_MS = (parseInt(process.env.CACHE_DEFAULT_TTL, 10) || 15) * 1000;
|
|
|
|
| 12 |
|
| 13 |
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 60;
|
| 14 |
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000;
|
|
@@ -40,6 +41,18 @@ class CacheManager {
|
|
| 40 |
const now = Date.now();
|
| 41 |
const expiresAt = now + ttlMs;
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
this.store.set(key, {
|
| 44 |
value,
|
| 45 |
expiresAt,
|
|
@@ -53,12 +66,11 @@ class CacheManager {
|
|
| 53 |
const entry = this.store.get(key);
|
| 54 |
if (!entry) return null;
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
return null;
|
| 60 |
-
}
|
| 61 |
|
|
|
|
| 62 |
return entry;
|
| 63 |
}
|
| 64 |
|
|
@@ -76,7 +88,8 @@ class CacheManager {
|
|
| 76 |
let evictedCount = 0;
|
| 77 |
|
| 78 |
for (const [key, entry] of this.store.entries()) {
|
| 79 |
-
|
|
|
|
| 80 |
this.store.delete(key);
|
| 81 |
evictedCount++;
|
| 82 |
}
|
|
@@ -177,8 +190,11 @@ const rateLimiter = new RateLimiter();
|
|
| 177 |
// =========================================================================
|
| 178 |
// EXPRESS MIDDLEWARE
|
| 179 |
// =========================================================================
|
|
|
|
|
|
|
|
|
|
| 180 |
/**
|
| 181 |
-
* Express Middleware protecting route with Rate-Limiting and
|
| 182 |
* @param {number} ttlSeconds Custom TTL override in seconds
|
| 183 |
*/
|
| 184 |
export function cacheMiddleware(ttlSeconds) {
|
|
@@ -215,43 +231,80 @@ export function cacheMiddleware(ttlSeconds) {
|
|
| 215 |
}
|
| 216 |
|
| 217 |
// 3. Perform Cache Lookup
|
| 218 |
-
// Generate normalized cache key based on route path and alphabetically sorted query parameters
|
| 219 |
const sortedQueries = Object.keys(req.query)
|
| 220 |
.sort()
|
| 221 |
.map(k => `${k}=${req.query[k]}`)
|
| 222 |
.join('&');
|
| 223 |
|
| 224 |
const cacheKey = sortedQueries ? `${req.path}?${sortedQueries}` : req.path;
|
| 225 |
-
const
|
|
|
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
metrics.hits++;
|
| 230 |
-
const
|
| 231 |
-
const remainingTtlSecs = Math.max(0, Math.ceil((cachedEntry.expiresAt - now) / 1000));
|
| 232 |
-
|
| 233 |
res.setHeader('X-Cache', 'HIT');
|
| 234 |
res.setHeader('X-Cache-TTL-Remaining', remainingTtlSecs);
|
| 235 |
-
|
| 236 |
-
// Serve cached JSON payload
|
| 237 |
-
return res.json(cachedEntry.value);
|
| 238 |
}
|
| 239 |
|
| 240 |
-
// Cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
metrics.misses++;
|
| 242 |
res.setHeader('X-Cache', 'MISS');
|
| 243 |
-
res.setHeader('X-Cache-TTL-Remaining', Math.ceil(ttlMs / 1000));
|
| 244 |
|
| 245 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
const originalJson = res.json;
|
| 247 |
res.json = function (body) {
|
| 248 |
-
|
| 249 |
-
res.json = originalJson;
|
| 250 |
|
| 251 |
-
//
|
| 252 |
cache.set(cacheKey, body, ttlMs);
|
| 253 |
|
| 254 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
return res.json(body);
|
| 256 |
};
|
| 257 |
|
|
|
|
| 9 |
// Configuration flags
|
| 10 |
const IS_CACHE_ENABLED = process.env.CACHE_ENABLED !== 'false';
|
| 11 |
const DEFAULT_TTL_MS = (parseInt(process.env.CACHE_DEFAULT_TTL, 10) || 15) * 1000;
|
| 12 |
+
const CACHE_MAX_KEYS = parseInt(process.env.CACHE_MAX_KEYS, 10) || 1000;
|
| 13 |
|
| 14 |
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 60;
|
| 15 |
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000;
|
|
|
|
| 41 |
const now = Date.now();
|
| 42 |
const expiresAt = now + ttlMs;
|
| 43 |
|
| 44 |
+
if (this.store.has(key)) {
|
| 45 |
+
// Delete existing to update its insertion order position (LRU)
|
| 46 |
+
this.store.delete(key);
|
| 47 |
+
} else if (this.store.size >= CACHE_MAX_KEYS) {
|
| 48 |
+
// Evict the least recently used (oldest) key
|
| 49 |
+
const oldestKey = this.store.keys().next().value;
|
| 50 |
+
if (oldestKey !== undefined) {
|
| 51 |
+
this.store.delete(oldestKey);
|
| 52 |
+
console.log(`[CacheManager LRU] Evicted oldest key: ${oldestKey}`);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
this.store.set(key, {
|
| 57 |
value,
|
| 58 |
expiresAt,
|
|
|
|
| 66 |
const entry = this.store.get(key);
|
| 67 |
if (!entry) return null;
|
| 68 |
|
| 69 |
+
// Refresh insertion order position for LRU
|
| 70 |
+
this.store.delete(key);
|
| 71 |
+
this.store.set(key, entry);
|
|
|
|
|
|
|
| 72 |
|
| 73 |
+
// Return the entry even if expired. Freshness logic is handled by middleware
|
| 74 |
return entry;
|
| 75 |
}
|
| 76 |
|
|
|
|
| 88 |
let evictedCount = 0;
|
| 89 |
|
| 90 |
for (const [key, entry] of this.store.entries()) {
|
| 91 |
+
// Keep stale data in memory for up to 24 hours so it can be served during heavy compute spikes
|
| 92 |
+
if (now > entry.expiresAt + 24 * 60 * 60 * 1000) {
|
| 93 |
this.store.delete(key);
|
| 94 |
evictedCount++;
|
| 95 |
}
|
|
|
|
| 190 |
// =========================================================================
|
| 191 |
// EXPRESS MIDDLEWARE
|
| 192 |
// =========================================================================
|
| 193 |
+
|
| 194 |
+
const pendingRequests = new Map();
|
| 195 |
+
|
| 196 |
/**
|
| 197 |
+
* Express Middleware protecting route with Rate-Limiting, Coalescing, and Stale-While-Revalidate Cache
|
| 198 |
* @param {number} ttlSeconds Custom TTL override in seconds
|
| 199 |
*/
|
| 200 |
export function cacheMiddleware(ttlSeconds) {
|
|
|
|
| 231 |
}
|
| 232 |
|
| 233 |
// 3. Perform Cache Lookup
|
|
|
|
| 234 |
const sortedQueries = Object.keys(req.query)
|
| 235 |
.sort()
|
| 236 |
.map(k => `${k}=${req.query[k]}`)
|
| 237 |
.join('&');
|
| 238 |
|
| 239 |
const cacheKey = sortedQueries ? `${req.path}?${sortedQueries}` : req.path;
|
| 240 |
+
const entry = cache.get(cacheKey);
|
| 241 |
+
const now = Date.now();
|
| 242 |
|
| 243 |
+
// CASE A: Cache is FRESH
|
| 244 |
+
if (entry && now <= entry.expiresAt) {
|
| 245 |
metrics.hits++;
|
| 246 |
+
const remainingTtlSecs = Math.max(0, Math.ceil((entry.expiresAt - now) / 1000));
|
|
|
|
|
|
|
| 247 |
res.setHeader('X-Cache', 'HIT');
|
| 248 |
res.setHeader('X-Cache-TTL-Remaining', remainingTtlSecs);
|
| 249 |
+
return res.json(entry.value);
|
|
|
|
|
|
|
| 250 |
}
|
| 251 |
|
| 252 |
+
// CASE B: Cache is STALE or MISS, and another request is already computing it
|
| 253 |
+
if (pendingRequests.has(cacheKey)) {
|
| 254 |
+
if (entry) {
|
| 255 |
+
// Stale-While-Revalidate: Return old data immediately to avoid waiting
|
| 256 |
+
metrics.hits++;
|
| 257 |
+
res.setHeader('X-Cache', 'STALE');
|
| 258 |
+
return res.json(entry.value);
|
| 259 |
+
} else {
|
| 260 |
+
// Request Coalescing: No old data exists, so wait for the pending request to finish
|
| 261 |
+
metrics.misses++;
|
| 262 |
+
res.setHeader('X-Cache', 'COALESCED');
|
| 263 |
+
try {
|
| 264 |
+
const value = await pendingRequests.get(cacheKey);
|
| 265 |
+
return res.json(value);
|
| 266 |
+
} catch (e) {
|
| 267 |
+
return res.status(500).json({ error: 'Internal Server Error computing cache' });
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// CASE C: Cache is STALE or MISS, and WE are the first request to trigger compute
|
| 273 |
metrics.misses++;
|
| 274 |
res.setHeader('X-Cache', 'MISS');
|
|
|
|
| 275 |
|
| 276 |
+
// Create a pending Promise that others can wait on
|
| 277 |
+
let resolvePending;
|
| 278 |
+
let rejectPending;
|
| 279 |
+
const pendingPromise = new Promise((resolve, reject) => {
|
| 280 |
+
resolvePending = resolve;
|
| 281 |
+
rejectPending = reject;
|
| 282 |
+
});
|
| 283 |
+
pendingRequests.set(cacheKey, pendingPromise);
|
| 284 |
+
|
| 285 |
+
// Timeout safety net to prevent frozen promises if next() hangs forever
|
| 286 |
+
const safetyTimeout = setTimeout(() => {
|
| 287 |
+
if (pendingRequests.get(cacheKey) === pendingPromise) {
|
| 288 |
+
pendingRequests.delete(cacheKey);
|
| 289 |
+
rejectPending(new Error('Cache compute timeout'));
|
| 290 |
+
}
|
| 291 |
+
}, 5 * 60 * 1000);
|
| 292 |
+
|
| 293 |
+
// Intercept res.json to store the result
|
| 294 |
const originalJson = res.json;
|
| 295 |
res.json = function (body) {
|
| 296 |
+
clearTimeout(safetyTimeout);
|
| 297 |
+
res.json = originalJson; // Restore
|
| 298 |
|
| 299 |
+
// Save fresh data
|
| 300 |
cache.set(cacheKey, body, ttlMs);
|
| 301 |
|
| 302 |
+
// Resolve the promise to unblock anyone who was coalesced (waiting)
|
| 303 |
+
resolvePending(body);
|
| 304 |
+
if (pendingRequests.get(cacheKey) === pendingPromise) {
|
| 305 |
+
pendingRequests.delete(cacheKey);
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
return res.json(body);
|
| 309 |
};
|
| 310 |
|