layercache 1.0.0 → 1.0.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/README.md +85 -6
- package/dist/index.cjs +437 -59
- package/dist/index.d.cts +64 -4
- package/dist/index.d.ts +64 -4
- package/dist/index.js +436 -59
- package/package.json +5 -1
- package/packages/nestjs/dist/index.cjs +368 -57
- package/packages/nestjs/dist/index.d.cts +23 -0
- package/packages/nestjs/dist/index.d.ts +23 -0
- package/packages/nestjs/dist/index.js +368 -57
|
@@ -43,6 +43,81 @@ var import_common = require("@nestjs/common");
|
|
|
43
43
|
// ../../src/CacheStack.ts
|
|
44
44
|
var import_node_crypto = require("crypto");
|
|
45
45
|
|
|
46
|
+
// ../../src/internal/StoredValue.ts
|
|
47
|
+
function isStoredValueEnvelope(value) {
|
|
48
|
+
return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
|
|
49
|
+
}
|
|
50
|
+
function createStoredValueEnvelope(options) {
|
|
51
|
+
const now = options.now ?? Date.now();
|
|
52
|
+
const freshTtlSeconds = normalizePositiveSeconds(options.freshTtlSeconds);
|
|
53
|
+
const staleWhileRevalidateSeconds = normalizePositiveSeconds(options.staleWhileRevalidateSeconds);
|
|
54
|
+
const staleIfErrorSeconds = normalizePositiveSeconds(options.staleIfErrorSeconds);
|
|
55
|
+
const freshUntil = freshTtlSeconds ? now + freshTtlSeconds * 1e3 : null;
|
|
56
|
+
const staleUntil = freshUntil && staleWhileRevalidateSeconds ? freshUntil + staleWhileRevalidateSeconds * 1e3 : null;
|
|
57
|
+
const errorUntil = freshUntil && staleIfErrorSeconds ? freshUntil + staleIfErrorSeconds * 1e3 : null;
|
|
58
|
+
return {
|
|
59
|
+
__layercache: 1,
|
|
60
|
+
kind: options.kind,
|
|
61
|
+
value: options.value,
|
|
62
|
+
freshUntil,
|
|
63
|
+
staleUntil,
|
|
64
|
+
errorUntil
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function resolveStoredValue(stored, now = Date.now()) {
|
|
68
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
69
|
+
return { state: "fresh", value: stored, stored };
|
|
70
|
+
}
|
|
71
|
+
if (stored.freshUntil === null || stored.freshUntil > now) {
|
|
72
|
+
return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
73
|
+
}
|
|
74
|
+
if (stored.staleUntil !== null && stored.staleUntil > now) {
|
|
75
|
+
return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
76
|
+
}
|
|
77
|
+
if (stored.errorUntil !== null && stored.errorUntil > now) {
|
|
78
|
+
return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
79
|
+
}
|
|
80
|
+
return { state: "expired", value: null, stored, envelope: stored };
|
|
81
|
+
}
|
|
82
|
+
function unwrapStoredValue(stored) {
|
|
83
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
84
|
+
return stored;
|
|
85
|
+
}
|
|
86
|
+
if (stored.kind === "empty") {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return stored.value ?? null;
|
|
90
|
+
}
|
|
91
|
+
function remainingStoredTtlSeconds(stored, now = Date.now()) {
|
|
92
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
93
|
+
return void 0;
|
|
94
|
+
}
|
|
95
|
+
const expiry = maxExpiry(stored);
|
|
96
|
+
if (expiry === null) {
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
const remainingMs = expiry - now;
|
|
100
|
+
if (remainingMs <= 0) {
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
return Math.max(1, Math.ceil(remainingMs / 1e3));
|
|
104
|
+
}
|
|
105
|
+
function maxExpiry(stored) {
|
|
106
|
+
const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
|
|
107
|
+
(value) => value !== null
|
|
108
|
+
);
|
|
109
|
+
if (values.length === 0) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return Math.max(...values);
|
|
113
|
+
}
|
|
114
|
+
function normalizePositiveSeconds(value) {
|
|
115
|
+
if (!value || value <= 0) {
|
|
116
|
+
return void 0;
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
46
121
|
// ../../src/invalidation/PatternMatcher.ts
|
|
47
122
|
var PatternMatcher = class {
|
|
48
123
|
static matches(pattern, value) {
|
|
@@ -309,6 +384,10 @@ var StampedeGuard = class {
|
|
|
309
384
|
};
|
|
310
385
|
|
|
311
386
|
// ../../src/CacheStack.ts
|
|
387
|
+
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
388
|
+
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
389
|
+
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
390
|
+
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
312
391
|
var EMPTY_METRICS = () => ({
|
|
313
392
|
hits: 0,
|
|
314
393
|
misses: 0,
|
|
@@ -316,7 +395,12 @@ var EMPTY_METRICS = () => ({
|
|
|
316
395
|
sets: 0,
|
|
317
396
|
deletes: 0,
|
|
318
397
|
backfills: 0,
|
|
319
|
-
invalidations: 0
|
|
398
|
+
invalidations: 0,
|
|
399
|
+
staleHits: 0,
|
|
400
|
+
refreshes: 0,
|
|
401
|
+
refreshErrors: 0,
|
|
402
|
+
writeFailures: 0,
|
|
403
|
+
singleFlightWaits: 0
|
|
320
404
|
});
|
|
321
405
|
var DebugLogger = class {
|
|
322
406
|
enabled;
|
|
@@ -328,7 +412,7 @@ var DebugLogger = class {
|
|
|
328
412
|
return;
|
|
329
413
|
}
|
|
330
414
|
const suffix = context ? ` ${JSON.stringify(context)}` : "";
|
|
331
|
-
console.debug(`[
|
|
415
|
+
console.debug(`[layercache] ${message}${suffix}`);
|
|
332
416
|
}
|
|
333
417
|
};
|
|
334
418
|
var CacheStack = class {
|
|
@@ -338,7 +422,7 @@ var CacheStack = class {
|
|
|
338
422
|
if (layers.length === 0) {
|
|
339
423
|
throw new Error("CacheStack requires at least one cache layer.");
|
|
340
424
|
}
|
|
341
|
-
const debugEnv = process.env.DEBUG?.split(",").includes("
|
|
425
|
+
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
342
426
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
343
427
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
344
428
|
this.startup = this.initialize();
|
|
@@ -352,49 +436,46 @@ var CacheStack = class {
|
|
|
352
436
|
unsubscribeInvalidation;
|
|
353
437
|
logger;
|
|
354
438
|
tagIndex;
|
|
439
|
+
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
355
440
|
async get(key, fetcher, options) {
|
|
356
441
|
await this.startup;
|
|
357
|
-
const hit = await this.
|
|
442
|
+
const hit = await this.readFromLayers(key, options, "allow-stale");
|
|
358
443
|
if (hit.found) {
|
|
359
|
-
|
|
360
|
-
|
|
444
|
+
if (hit.state === "fresh") {
|
|
445
|
+
this.metrics.hits += 1;
|
|
446
|
+
return hit.value;
|
|
447
|
+
}
|
|
448
|
+
if (hit.state === "stale-while-revalidate") {
|
|
449
|
+
this.metrics.hits += 1;
|
|
450
|
+
this.metrics.staleHits += 1;
|
|
451
|
+
if (fetcher) {
|
|
452
|
+
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
453
|
+
}
|
|
454
|
+
return hit.value;
|
|
455
|
+
}
|
|
456
|
+
if (!fetcher) {
|
|
457
|
+
this.metrics.hits += 1;
|
|
458
|
+
this.metrics.staleHits += 1;
|
|
459
|
+
return hit.value;
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
return await this.fetchWithGuards(key, fetcher, options);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
this.metrics.staleHits += 1;
|
|
465
|
+
this.metrics.refreshErrors += 1;
|
|
466
|
+
this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
|
|
467
|
+
return hit.value;
|
|
468
|
+
}
|
|
361
469
|
}
|
|
362
470
|
this.metrics.misses += 1;
|
|
363
471
|
if (!fetcher) {
|
|
364
472
|
return null;
|
|
365
473
|
}
|
|
366
|
-
|
|
367
|
-
const secondHit = await this.getFromLayers(key, options);
|
|
368
|
-
if (secondHit.found) {
|
|
369
|
-
this.metrics.hits += 1;
|
|
370
|
-
return secondHit.value;
|
|
371
|
-
}
|
|
372
|
-
this.metrics.fetches += 1;
|
|
373
|
-
const fetched = await fetcher();
|
|
374
|
-
if (fetched === null || fetched === void 0) {
|
|
375
|
-
return null;
|
|
376
|
-
}
|
|
377
|
-
await this.set(key, fetched, options);
|
|
378
|
-
return fetched;
|
|
379
|
-
};
|
|
380
|
-
if (this.options.stampedePrevention === false) {
|
|
381
|
-
return runFetch();
|
|
382
|
-
}
|
|
383
|
-
return this.stampedeGuard.execute(key, runFetch);
|
|
474
|
+
return this.fetchWithGuards(key, fetcher, options);
|
|
384
475
|
}
|
|
385
476
|
async set(key, value, options) {
|
|
386
477
|
await this.startup;
|
|
387
|
-
await this.
|
|
388
|
-
if (options?.tags) {
|
|
389
|
-
await this.tagIndex.track(key, options.tags);
|
|
390
|
-
} else {
|
|
391
|
-
await this.tagIndex.touch(key);
|
|
392
|
-
}
|
|
393
|
-
this.metrics.sets += 1;
|
|
394
|
-
this.logger.debug("set", { key, tags: options?.tags });
|
|
395
|
-
if (this.options.publishSetInvalidation !== false) {
|
|
396
|
-
await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
|
|
397
|
-
}
|
|
478
|
+
await this.storeEntry(key, "value", value, options);
|
|
398
479
|
}
|
|
399
480
|
async delete(key) {
|
|
400
481
|
await this.startup;
|
|
@@ -410,7 +491,48 @@ var CacheStack = class {
|
|
|
410
491
|
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
411
492
|
}
|
|
412
493
|
async mget(entries) {
|
|
413
|
-
|
|
494
|
+
if (entries.length === 0) {
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
const canFastPath = entries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
498
|
+
if (!canFastPath) {
|
|
499
|
+
return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
|
|
500
|
+
}
|
|
501
|
+
await this.startup;
|
|
502
|
+
const pending = new Set(entries.map((_, index) => index));
|
|
503
|
+
const results = Array(entries.length).fill(null);
|
|
504
|
+
for (const layer of this.layers) {
|
|
505
|
+
const indexes = [...pending];
|
|
506
|
+
if (indexes.length === 0) {
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
const keys = indexes.map((index) => entries[index].key);
|
|
510
|
+
const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
|
|
511
|
+
for (let offset = 0; offset < values.length; offset += 1) {
|
|
512
|
+
const index = indexes[offset];
|
|
513
|
+
const stored = values[offset];
|
|
514
|
+
if (stored === null) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const resolved = resolveStoredValue(stored);
|
|
518
|
+
if (resolved.state === "expired") {
|
|
519
|
+
await layer.delete(entries[index].key);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
await this.tagIndex.touch(entries[index].key);
|
|
523
|
+
await this.backfill(entries[index].key, stored, this.layers.indexOf(layer) - 1, entries[index].options);
|
|
524
|
+
results[index] = resolved.value;
|
|
525
|
+
pending.delete(index);
|
|
526
|
+
this.metrics.hits += 1;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (pending.size > 0) {
|
|
530
|
+
for (const index of pending) {
|
|
531
|
+
await this.tagIndex.remove(entries[index].key);
|
|
532
|
+
this.metrics.misses += 1;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return results;
|
|
414
536
|
}
|
|
415
537
|
async mset(entries) {
|
|
416
538
|
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
|
|
@@ -436,6 +558,7 @@ var CacheStack = class {
|
|
|
436
558
|
async disconnect() {
|
|
437
559
|
await this.startup;
|
|
438
560
|
await this.unsubscribeInvalidation?.();
|
|
561
|
+
await Promise.allSettled(this.backgroundRefreshes.values());
|
|
439
562
|
}
|
|
440
563
|
async initialize() {
|
|
441
564
|
if (!this.options.invalidationBus) {
|
|
@@ -445,46 +568,225 @@ var CacheStack = class {
|
|
|
445
568
|
await this.handleInvalidationMessage(message);
|
|
446
569
|
});
|
|
447
570
|
}
|
|
448
|
-
async
|
|
571
|
+
async fetchWithGuards(key, fetcher, options) {
|
|
572
|
+
const fetchTask = async () => {
|
|
573
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
574
|
+
if (secondHit.found) {
|
|
575
|
+
this.metrics.hits += 1;
|
|
576
|
+
return secondHit.value;
|
|
577
|
+
}
|
|
578
|
+
return this.fetchAndPopulate(key, fetcher, options);
|
|
579
|
+
};
|
|
580
|
+
const singleFlightTask = async () => {
|
|
581
|
+
if (!this.options.singleFlightCoordinator) {
|
|
582
|
+
return fetchTask();
|
|
583
|
+
}
|
|
584
|
+
return this.options.singleFlightCoordinator.execute(
|
|
585
|
+
key,
|
|
586
|
+
this.resolveSingleFlightOptions(),
|
|
587
|
+
fetchTask,
|
|
588
|
+
() => this.waitForFreshValue(key, fetcher, options)
|
|
589
|
+
);
|
|
590
|
+
};
|
|
591
|
+
if (this.options.stampedePrevention === false) {
|
|
592
|
+
return singleFlightTask();
|
|
593
|
+
}
|
|
594
|
+
return this.stampedeGuard.execute(key, singleFlightTask);
|
|
595
|
+
}
|
|
596
|
+
async waitForFreshValue(key, fetcher, options) {
|
|
597
|
+
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
598
|
+
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
599
|
+
const deadline = Date.now() + timeoutMs;
|
|
600
|
+
this.metrics.singleFlightWaits += 1;
|
|
601
|
+
while (Date.now() < deadline) {
|
|
602
|
+
const hit = await this.readFromLayers(key, options, "fresh-only");
|
|
603
|
+
if (hit.found) {
|
|
604
|
+
this.metrics.hits += 1;
|
|
605
|
+
return hit.value;
|
|
606
|
+
}
|
|
607
|
+
await this.sleep(pollIntervalMs);
|
|
608
|
+
}
|
|
609
|
+
return this.fetchAndPopulate(key, fetcher, options);
|
|
610
|
+
}
|
|
611
|
+
async fetchAndPopulate(key, fetcher, options) {
|
|
612
|
+
this.metrics.fetches += 1;
|
|
613
|
+
const fetched = await fetcher();
|
|
614
|
+
if (fetched === null || fetched === void 0) {
|
|
615
|
+
if (!this.shouldNegativeCache(options)) {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
await this.storeEntry(key, "empty", null, options);
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
await this.storeEntry(key, "value", fetched, options);
|
|
622
|
+
return fetched;
|
|
623
|
+
}
|
|
624
|
+
async storeEntry(key, kind, value, options) {
|
|
625
|
+
await this.writeAcrossLayers(key, kind, value, options);
|
|
626
|
+
if (options?.tags) {
|
|
627
|
+
await this.tagIndex.track(key, options.tags);
|
|
628
|
+
} else {
|
|
629
|
+
await this.tagIndex.touch(key);
|
|
630
|
+
}
|
|
631
|
+
this.metrics.sets += 1;
|
|
632
|
+
this.logger.debug("set", { key, kind, tags: options?.tags });
|
|
633
|
+
if (this.options.publishSetInvalidation !== false) {
|
|
634
|
+
await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
async readFromLayers(key, options, mode) {
|
|
638
|
+
let sawRetainableValue = false;
|
|
449
639
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
450
640
|
const layer = this.layers[index];
|
|
451
|
-
const
|
|
452
|
-
if (
|
|
641
|
+
const stored = await this.readLayerEntry(layer, key);
|
|
642
|
+
if (stored === null) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const resolved = resolveStoredValue(stored);
|
|
646
|
+
if (resolved.state === "expired") {
|
|
647
|
+
await layer.delete(key);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
sawRetainableValue = true;
|
|
651
|
+
if (mode === "fresh-only" && resolved.state !== "fresh") {
|
|
453
652
|
continue;
|
|
454
653
|
}
|
|
455
654
|
await this.tagIndex.touch(key);
|
|
456
|
-
await this.backfill(key,
|
|
457
|
-
this.logger.debug("hit", { key, layer: layer.name });
|
|
458
|
-
return { found: true, value };
|
|
655
|
+
await this.backfill(key, stored, index - 1, options);
|
|
656
|
+
this.logger.debug("hit", { key, layer: layer.name, state: resolved.state });
|
|
657
|
+
return { found: true, value: resolved.value, stored, state: resolved.state };
|
|
459
658
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
659
|
+
if (!sawRetainableValue) {
|
|
660
|
+
await this.tagIndex.remove(key);
|
|
661
|
+
}
|
|
662
|
+
this.logger.debug("miss", { key, mode });
|
|
663
|
+
return { found: false, value: null, stored: null, state: "miss" };
|
|
664
|
+
}
|
|
665
|
+
async readLayerEntry(layer, key) {
|
|
666
|
+
if (layer.getEntry) {
|
|
667
|
+
return layer.getEntry(key);
|
|
668
|
+
}
|
|
669
|
+
return layer.get(key);
|
|
463
670
|
}
|
|
464
|
-
async backfill(key,
|
|
671
|
+
async backfill(key, stored, upToIndex, options) {
|
|
465
672
|
if (upToIndex < 0) {
|
|
466
673
|
return;
|
|
467
674
|
}
|
|
468
675
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
469
676
|
const layer = this.layers[index];
|
|
470
|
-
|
|
677
|
+
const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
678
|
+
await layer.set(key, stored, ttl);
|
|
471
679
|
this.metrics.backfills += 1;
|
|
472
680
|
this.logger.debug("backfill", { key, layer: layer.name });
|
|
473
681
|
}
|
|
474
682
|
}
|
|
475
|
-
async
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
683
|
+
async writeAcrossLayers(key, kind, value, options) {
|
|
684
|
+
const now = Date.now();
|
|
685
|
+
const operations = this.layers.map((layer) => async () => {
|
|
686
|
+
const freshTtl = this.resolveFreshTtl(layer.name, kind, options, layer.defaultTtl);
|
|
687
|
+
const staleWhileRevalidate = this.resolveLayerSeconds(
|
|
688
|
+
layer.name,
|
|
689
|
+
options?.staleWhileRevalidate,
|
|
690
|
+
this.options.staleWhileRevalidate
|
|
691
|
+
);
|
|
692
|
+
const staleIfError = this.resolveLayerSeconds(
|
|
693
|
+
layer.name,
|
|
694
|
+
options?.staleIfError,
|
|
695
|
+
this.options.staleIfError
|
|
696
|
+
);
|
|
697
|
+
const payload = createStoredValueEnvelope({
|
|
698
|
+
kind,
|
|
699
|
+
value,
|
|
700
|
+
freshTtlSeconds: freshTtl,
|
|
701
|
+
staleWhileRevalidateSeconds: staleWhileRevalidate,
|
|
702
|
+
staleIfErrorSeconds: staleIfError,
|
|
703
|
+
now
|
|
704
|
+
});
|
|
705
|
+
const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
|
|
706
|
+
await layer.set(key, payload, ttl);
|
|
707
|
+
});
|
|
708
|
+
await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
|
|
479
709
|
}
|
|
480
|
-
|
|
481
|
-
if (
|
|
482
|
-
|
|
710
|
+
async executeLayerOperations(operations, context) {
|
|
711
|
+
if (this.options.writePolicy !== "best-effort") {
|
|
712
|
+
await Promise.all(operations.map((operation) => operation()));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const results = await Promise.allSettled(operations.map((operation) => operation()));
|
|
716
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
717
|
+
if (failures.length === 0) {
|
|
718
|
+
return;
|
|
483
719
|
}
|
|
484
|
-
|
|
485
|
-
|
|
720
|
+
this.metrics.writeFailures += failures.length;
|
|
721
|
+
this.logger.debug("write-failure", {
|
|
722
|
+
...context,
|
|
723
|
+
failures: failures.map((failure) => this.formatError(failure.reason))
|
|
724
|
+
});
|
|
725
|
+
if (failures.length === operations.length) {
|
|
726
|
+
throw new AggregateError(
|
|
727
|
+
failures.map((failure) => failure.reason),
|
|
728
|
+
`${context.action} failed for every cache layer`
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
resolveFreshTtl(layerName, kind, options, fallbackTtl) {
|
|
733
|
+
const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
|
|
734
|
+
layerName,
|
|
735
|
+
options?.negativeTtl,
|
|
736
|
+
this.options.negativeTtl,
|
|
737
|
+
this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
738
|
+
) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
|
|
739
|
+
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
|
|
740
|
+
return this.applyJitter(baseTtl, jitter);
|
|
741
|
+
}
|
|
742
|
+
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
743
|
+
if (override !== void 0) {
|
|
744
|
+
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
486
745
|
}
|
|
487
|
-
|
|
746
|
+
if (globalDefault !== void 0) {
|
|
747
|
+
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
748
|
+
}
|
|
749
|
+
return fallback;
|
|
750
|
+
}
|
|
751
|
+
readLayerNumber(layerName, value) {
|
|
752
|
+
if (typeof value === "number") {
|
|
753
|
+
return value;
|
|
754
|
+
}
|
|
755
|
+
return value[layerName];
|
|
756
|
+
}
|
|
757
|
+
applyJitter(ttl, jitter) {
|
|
758
|
+
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
759
|
+
return ttl;
|
|
760
|
+
}
|
|
761
|
+
const delta = (Math.random() * 2 - 1) * jitter;
|
|
762
|
+
return Math.max(1, Math.round(ttl + delta));
|
|
763
|
+
}
|
|
764
|
+
shouldNegativeCache(options) {
|
|
765
|
+
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
766
|
+
}
|
|
767
|
+
scheduleBackgroundRefresh(key, fetcher, options) {
|
|
768
|
+
if (this.backgroundRefreshes.has(key)) {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const refresh = (async () => {
|
|
772
|
+
this.metrics.refreshes += 1;
|
|
773
|
+
try {
|
|
774
|
+
await this.fetchWithGuards(key, fetcher, options);
|
|
775
|
+
} catch (error) {
|
|
776
|
+
this.metrics.refreshErrors += 1;
|
|
777
|
+
this.logger.debug("refresh-error", { key, error: this.formatError(error) });
|
|
778
|
+
} finally {
|
|
779
|
+
this.backgroundRefreshes.delete(key);
|
|
780
|
+
}
|
|
781
|
+
})();
|
|
782
|
+
this.backgroundRefreshes.set(key, refresh);
|
|
783
|
+
}
|
|
784
|
+
resolveSingleFlightOptions() {
|
|
785
|
+
return {
|
|
786
|
+
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
787
|
+
waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
|
|
788
|
+
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
|
|
789
|
+
};
|
|
488
790
|
}
|
|
489
791
|
async deleteKeys(keys) {
|
|
490
792
|
if (keys.length === 0) {
|
|
@@ -541,6 +843,15 @@ var CacheStack = class {
|
|
|
541
843
|
}
|
|
542
844
|
}
|
|
543
845
|
}
|
|
846
|
+
formatError(error) {
|
|
847
|
+
if (error instanceof Error) {
|
|
848
|
+
return error.message;
|
|
849
|
+
}
|
|
850
|
+
return String(error);
|
|
851
|
+
}
|
|
852
|
+
sleep(ms) {
|
|
853
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
854
|
+
}
|
|
544
855
|
};
|
|
545
856
|
|
|
546
857
|
// src/module.ts
|
|
@@ -2,11 +2,16 @@ import { DynamicModule } from '@nestjs/common';
|
|
|
2
2
|
|
|
3
3
|
declare const CACHE_STACK: unique symbol;
|
|
4
4
|
|
|
5
|
+
interface LayerTtlMap {
|
|
6
|
+
[layerName: string]: number | undefined;
|
|
7
|
+
}
|
|
5
8
|
interface CacheLayer {
|
|
6
9
|
readonly name: string;
|
|
7
10
|
readonly defaultTtl?: number;
|
|
8
11
|
readonly isLocal?: boolean;
|
|
9
12
|
get<T>(key: string): Promise<T | null>;
|
|
13
|
+
getEntry?<T = unknown>(key: string): Promise<T | null>;
|
|
14
|
+
getMany?<T>(keys: string[]): Promise<Array<T | null>>;
|
|
10
15
|
set(key: string, value: unknown, ttl?: number): Promise<void>;
|
|
11
16
|
delete(key: string): Promise<void>;
|
|
12
17
|
clear(): Promise<void>;
|
|
@@ -34,6 +39,14 @@ interface InvalidationBus {
|
|
|
34
39
|
subscribe(handler: (message: InvalidationMessage) => Promise<void> | void): Promise<() => Promise<void> | void>;
|
|
35
40
|
publish(message: InvalidationMessage): Promise<void>;
|
|
36
41
|
}
|
|
42
|
+
interface CacheSingleFlightExecutionOptions {
|
|
43
|
+
leaseMs: number;
|
|
44
|
+
waitTimeoutMs: number;
|
|
45
|
+
pollIntervalMs: number;
|
|
46
|
+
}
|
|
47
|
+
interface CacheSingleFlightCoordinator {
|
|
48
|
+
execute<T>(key: string, options: CacheSingleFlightExecutionOptions, worker: () => Promise<T>, waiter: () => Promise<T>): Promise<T>;
|
|
49
|
+
}
|
|
37
50
|
interface CacheStackOptions {
|
|
38
51
|
logger?: CacheLogger | boolean;
|
|
39
52
|
metrics?: boolean;
|
|
@@ -41,6 +54,16 @@ interface CacheStackOptions {
|
|
|
41
54
|
invalidationBus?: InvalidationBus;
|
|
42
55
|
tagIndex?: CacheTagIndex;
|
|
43
56
|
publishSetInvalidation?: boolean;
|
|
57
|
+
negativeCaching?: boolean;
|
|
58
|
+
negativeTtl?: number | LayerTtlMap;
|
|
59
|
+
staleWhileRevalidate?: number | LayerTtlMap;
|
|
60
|
+
staleIfError?: number | LayerTtlMap;
|
|
61
|
+
ttlJitter?: number | LayerTtlMap;
|
|
62
|
+
writePolicy?: 'strict' | 'best-effort';
|
|
63
|
+
singleFlightCoordinator?: CacheSingleFlightCoordinator;
|
|
64
|
+
singleFlightLeaseMs?: number;
|
|
65
|
+
singleFlightTimeoutMs?: number;
|
|
66
|
+
singleFlightPollMs?: number;
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
interface CacheStackModuleOptions {
|
|
@@ -2,11 +2,16 @@ import { DynamicModule } from '@nestjs/common';
|
|
|
2
2
|
|
|
3
3
|
declare const CACHE_STACK: unique symbol;
|
|
4
4
|
|
|
5
|
+
interface LayerTtlMap {
|
|
6
|
+
[layerName: string]: number | undefined;
|
|
7
|
+
}
|
|
5
8
|
interface CacheLayer {
|
|
6
9
|
readonly name: string;
|
|
7
10
|
readonly defaultTtl?: number;
|
|
8
11
|
readonly isLocal?: boolean;
|
|
9
12
|
get<T>(key: string): Promise<T | null>;
|
|
13
|
+
getEntry?<T = unknown>(key: string): Promise<T | null>;
|
|
14
|
+
getMany?<T>(keys: string[]): Promise<Array<T | null>>;
|
|
10
15
|
set(key: string, value: unknown, ttl?: number): Promise<void>;
|
|
11
16
|
delete(key: string): Promise<void>;
|
|
12
17
|
clear(): Promise<void>;
|
|
@@ -34,6 +39,14 @@ interface InvalidationBus {
|
|
|
34
39
|
subscribe(handler: (message: InvalidationMessage) => Promise<void> | void): Promise<() => Promise<void> | void>;
|
|
35
40
|
publish(message: InvalidationMessage): Promise<void>;
|
|
36
41
|
}
|
|
42
|
+
interface CacheSingleFlightExecutionOptions {
|
|
43
|
+
leaseMs: number;
|
|
44
|
+
waitTimeoutMs: number;
|
|
45
|
+
pollIntervalMs: number;
|
|
46
|
+
}
|
|
47
|
+
interface CacheSingleFlightCoordinator {
|
|
48
|
+
execute<T>(key: string, options: CacheSingleFlightExecutionOptions, worker: () => Promise<T>, waiter: () => Promise<T>): Promise<T>;
|
|
49
|
+
}
|
|
37
50
|
interface CacheStackOptions {
|
|
38
51
|
logger?: CacheLogger | boolean;
|
|
39
52
|
metrics?: boolean;
|
|
@@ -41,6 +54,16 @@ interface CacheStackOptions {
|
|
|
41
54
|
invalidationBus?: InvalidationBus;
|
|
42
55
|
tagIndex?: CacheTagIndex;
|
|
43
56
|
publishSetInvalidation?: boolean;
|
|
57
|
+
negativeCaching?: boolean;
|
|
58
|
+
negativeTtl?: number | LayerTtlMap;
|
|
59
|
+
staleWhileRevalidate?: number | LayerTtlMap;
|
|
60
|
+
staleIfError?: number | LayerTtlMap;
|
|
61
|
+
ttlJitter?: number | LayerTtlMap;
|
|
62
|
+
writePolicy?: 'strict' | 'best-effort';
|
|
63
|
+
singleFlightCoordinator?: CacheSingleFlightCoordinator;
|
|
64
|
+
singleFlightLeaseMs?: number;
|
|
65
|
+
singleFlightTimeoutMs?: number;
|
|
66
|
+
singleFlightPollMs?: number;
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
interface CacheStackModuleOptions {
|