sqlcache 2.9.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -73,15 +73,14 @@ function createClient() {
73
73
  // timer value (~24.8 days) so the retry below really does not give up.
74
74
  connect_timeout: 2147483647,
75
75
  // Unlimited retry — exponential backoff with jitter, never gives up
76
+ // (each attempt is logged once, by the 'reconnecting' handler below)
76
77
  retry_strategy: (options) => {
77
78
  const exp = Math.min(
78
79
  RECONNECT_BASE_DELAY * Math.pow(2, options.attempt - 1),
79
80
  RECONNECT_MAX_DELAY
80
81
  );
81
82
  const jitter = Math.floor(Math.random() * exp * 0.2);
82
- const delay = exp + jitter;
83
- console.log(`Redis reconnecting in ${delay}ms (attempt ${options.attempt})`);
84
- return delay;
83
+ return exp + jitter;
85
84
  }
86
85
  });
87
86
 
@@ -356,9 +355,11 @@ async function deleteMatching(pattern) {
356
355
  // ── Missed invalidations ───────────────────────────────────────
357
356
  // Deletes that failed because Redis was unreachable are replayed once it is ready again, so a
358
357
  // write made during an outage does not leave stale entries behind for the rest of their TTL.
359
- // Bounded: beyond the limit the oldest entries are dropped (as every entry was before).
358
+ // Bounded: beyond the limit the oldest entries are dropped (as every entry was before), which
359
+ // is logged once per outage.
360
360
  const MAX_MISSED_INVALIDATIONS = 1000;
361
361
  const missedInvalidations = new Map();
362
+ let missedOverflowLogged = false;
362
363
 
363
364
  function rememberInvalidations(type, values) {
364
365
  for (const value of values) {
@@ -367,11 +368,16 @@ function rememberInvalidations(type, values) {
367
368
  missedInvalidations.set(id, { type, value });
368
369
  if (missedInvalidations.size > MAX_MISSED_INVALIDATIONS) {
369
370
  missedInvalidations.delete(missedInvalidations.keys().next().value);
371
+ if (!missedOverflowLogged) {
372
+ missedOverflowLogged = true;
373
+ console.error(`Redis: more than ${MAX_MISSED_INVALIDATIONS} invalidations were missed while it was unavailable; the oldest were dropped and those entries may stay stale until they expire`);
374
+ }
370
375
  }
371
376
  }
372
377
  }
373
378
 
374
379
  function replayInvalidations() {
380
+ missedOverflowLogged = false;
375
381
  if (missedInvalidations.size === 0) return;
376
382
  const missed = [...missedInvalidations.values()];
377
383
  missedInvalidations.clear();
@@ -395,8 +401,80 @@ function replayInvalidations() {
395
401
 
396
402
  // ── Helpers ────────────────────────────────────────────────────
397
403
 
404
+ // REDIS_VHOST is read on every call but meant to be set once: it is shared by every concurrent
405
+ // request. A change at runtime (as opposed to the first value used) is warned about once.
406
+ let firstVhost = null;
407
+ let vhostChangeWarned = false;
408
+
398
409
  function _namespaceKey(key) {
399
- return process.env.REDIS_VHOST ? `${process.env.REDIS_VHOST}:${key}` : key;
410
+ const vhost = process.env.REDIS_VHOST;
411
+ if (!vhostChangeWarned) {
412
+ const current = vhost || '';
413
+ if (firstVhost === null) {
414
+ firstVhost = current;
415
+ } else if (current !== firstVhost) {
416
+ vhostChangeWarned = true;
417
+ console.warn(`sqlcache: REDIS_VHOST changed at runtime ("${firstVhost}" -> "${current}"). It is shared by every concurrent request; for per-tenant caches pass a per-call database or prefix the cache names (see docs/USE-CASE-SAAS.md).`);
418
+ }
419
+ }
420
+ return vhost ? `${vhost}:${key}` : key;
421
+ }
422
+
423
+ // ── Stale-fill guard ───────────────────────────────────────────
424
+ // A read-through fill whose SELECT read rows before a write, but whose SETEX comes after that
425
+ // write's invalidation, would store the old rows again for the whole TTL. So every delete is
426
+ // recorded when delKeyItem/delPrefixKeyItem is called (before any wait, whether it then succeeds,
427
+ // fails or is kept for replay) under an increasing sequence number. A fill carries the number read
428
+ // before its SELECT started and skips SETEX when a delete recorded since then covers its key:
429
+ // a deleted key matches exactly, a deleted pattern by its literal prefix (the part before the
430
+ // first glob character). That may skip a fill needlessly (harmless), but never stores one over
431
+ // a delete that covers it. Only deletes made through this process are seen.
432
+ const DELETE_LOG_SIZE = 1000;
433
+ const deleteLog = new Array(DELETE_LOG_SIZE); // the n-th delete is at index n % DELETE_LOG_SIZE
434
+ let deleteSequence = 0;
435
+ const GLOB_CHARS = /[*?[\\]/;
436
+
437
+ function recordDelete(entry) {
438
+ deleteSequence++;
439
+ deleteLog[deleteSequence % DELETE_LOG_SIZE] = entry;
440
+ }
441
+
442
+ /** The part of a SCAN MATCH pattern before its first glob character */
443
+ function literalPrefix(pattern) {
444
+ const glob = pattern.search(GLOB_CHARS);
445
+ return glob === -1 ? pattern : pattern.slice(0, glob);
446
+ }
447
+
448
+ /**
449
+ * True when a delete recorded after `sequence` covers `namespacedKey`, or may do so because the
450
+ * log no longer reaches back to `sequence`. Only the deletes newer than `sequence` are checked.
451
+ */
452
+ function deletedSince(sequence, namespacedKey) {
453
+ const newer = deleteSequence - sequence;
454
+ if (newer === 0) return false;
455
+ if (!(newer > 0 && newer <= DELETE_LOG_SIZE)) return true;
456
+ const key = String(namespacedKey); // as Redis sees it (e.g. a numeric cache name)
457
+ for (let n = deleteSequence; n > sequence; n--) {
458
+ const entry = deleteLog[n % DELETE_LOG_SIZE];
459
+ if (entry.prefix !== undefined ? key.startsWith(entry.prefix) : key === entry.key) {
460
+ return true;
461
+ }
462
+ }
463
+ return false;
464
+ }
465
+
466
+ /**
467
+ * Stores a read-through fill like addArrayItem, unless a delete recorded after `sequence` (read
468
+ * before the fill's SELECT started) covers the key. Checked right before SETEX is sent, after
469
+ * any wait for the connection.
470
+ */
471
+ function fillArrayItem(key, array, sequence, expiryDate = 40000) {
472
+ return safeExec(async () => {
473
+ const namespacedKey = _namespaceKey(key);
474
+ if (deletedSince(sequence, namespacedKey)) return array;
475
+ await runCommand('setex', [namespacedKey, expiryDate, JSON.stringify(array)]);
476
+ return array;
477
+ }, array, { skippable: true });
400
478
  }
401
479
 
402
480
  // Connect eagerly unless Redis is disabled (after all state above is initialized)
@@ -453,6 +531,7 @@ module.exports = {
453
531
  return undefined;
454
532
  }
455
533
  const namespacedKeys = Array.isArray(keys) ? keys.map(key => _namespaceKey(key)) : [_namespaceKey(keys)];
534
+ for (const key of namespacedKeys) recordDelete({ key: String(key) });
456
535
  return safeExec(async () => {
457
536
  await runCommand('del', Array.isArray(keys) ? [namespacedKeys] : namespacedKeys, false);
458
537
  }, undefined, { onFailure: () => rememberInvalidations('key', namespacedKeys) });
@@ -467,6 +546,7 @@ module.exports = {
467
546
  async delPrefixKeyItem(keys) {
468
547
  const prefixes = Array.isArray(keys) ? mergeInvalidationPatterns(keys) : [keys];
469
548
  const patterns = prefixes.map(prefix => _namespaceKey(`${prefix}*`));
549
+ for (const pattern of patterns) recordDelete({ prefix: literalPrefix(pattern) });
470
550
  return safeExec(async () => {
471
551
  await Promise.all(patterns.map(deleteMatching));
472
552
  }, undefined, { onFailure: () => rememberInvalidations('pattern', patterns) });
@@ -474,8 +554,11 @@ module.exports = {
474
554
 
475
555
  /**
476
556
  * Check if Redis connection is healthy. Useful for health-check endpoints.
557
+ * With REDIS_ENABLED=false it does not open a connection by itself: false until the client
558
+ * exists (e.g. after getRedisClient()).
477
559
  */
478
560
  isRedisConnected() {
561
+ if (!REDIS_ENABLED && !client) return false;
479
562
  getClient();
480
563
  return isRedisReady;
481
564
  },
@@ -487,3 +570,13 @@ module.exports = {
487
570
  return getClient();
488
571
  }
489
572
  };
573
+
574
+ // ── Internal (for dbConnector; not re-exported by index.js) ────
575
+ // The stale-fill guard: sequence() is read before a read-through SELECT starts, fill() stores
576
+ // its rows. addArrayItem is the public function that fill() stands in for, so that dbConnector
577
+ // can tell when it has been replaced.
578
+ module.exports._fillGuard = {
579
+ addArrayItem: module.exports.addArrayItem,
580
+ sequence: () => deleteSequence,
581
+ fill: fillArrayItem
582
+ };