Sasha commited on
Commit
31d49af
·
1 Parent(s): 963afd2

feat: implement LRU cache eviction policy to prevent memory leaks

Browse files
Files changed (2) hide show
  1. server/.env.example +6 -0
  2. 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
- const now = Date.now();
57
- if (now > entry.expiresAt) {
58
- this.store.delete(key);
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
- if (now > entry.expiresAt) {
 
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 serving/saving Cache
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 cachedEntry = cache.get(cacheKey);
 
226
 
227
- if (cachedEntry) {
228
- // Cache Hit
229
  metrics.hits++;
230
- const now = Date.now();
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 Miss
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  metrics.misses++;
242
  res.setHeader('X-Cache', 'MISS');
243
- res.setHeader('X-Cache-TTL-Remaining', Math.ceil(ttlMs / 1000));
244
 
245
- // Intercept res.json to store the result in cache
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  const originalJson = res.json;
247
  res.json = function (body) {
248
- // Restore original res.json first to avoid recursive loops
249
- res.json = originalJson;
250
 
251
- // Store in CacheManager
252
  cache.set(cacheKey, body, ttlMs);
253
 
254
- // Call original response
 
 
 
 
 
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