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.
- package/.env.example +31 -17
- package/{readme.md → README.md} +106 -28
- package/core/autoInvalidate.js +14 -4
- package/core/autoKey.js +44 -8
- package/dbConnector.js +418 -65
- package/docs/API.md +161 -61
- package/docs/CONFIGURATION.md +155 -47
- package/docs/CONTRIBUTING.md +39 -29
- package/docs/EXAMPLES.md +70 -29
- package/docs/MIGRATION.md +141 -42
- package/docs/README.tr.md +75 -27
- package/docs/USE-CASE-BULK.md +48 -17
- package/docs/USE-CASE-ECOMMERCE.md +8 -2
- package/docs/USE-CASE-REALTIME.md +18 -6
- package/docs/USE-CASE-SAAS.md +24 -15
- package/index.d.ts +163 -50
- package/index.js +50 -23
- package/package.json +11 -5
- package/redis.Connector.js +98 -5
package/redis.Connector.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
};
|