dialcache 0.1.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/dist/index.js ADDED
@@ -0,0 +1,956 @@
1
+ import {
2
+ DialCacheRedisPayloadEncodingError,
3
+ DialCacheRedisPayloadError
4
+ } from "./chunk-S5VCTJID.js";
5
+
6
+ // src/config.ts
7
+ var CacheLayer = /* @__PURE__ */ ((CacheLayer2) => {
8
+ CacheLayer2["LOCAL"] = "local";
9
+ CacheLayer2["REMOTE"] = "remote";
10
+ return CacheLayer2;
11
+ })(CacheLayer || {});
12
+ var deterministicRampSampler = ({ key, layer }) => stablePercent(`${key.urn}:${layer}`);
13
+ var randomRampSampler = () => Math.random() * 100;
14
+ var DEFAULT_WATERMARK_TTL_SEC = 3600 * 4;
15
+ var DialCacheKeyConfig = class _DialCacheKeyConfig {
16
+ ttlSec;
17
+ ramp;
18
+ constructor(config) {
19
+ this.ttlSec = { ...config.ttlSec };
20
+ this.ramp = { ...config.ramp };
21
+ }
22
+ static enabled(ttlSec) {
23
+ return new _DialCacheKeyConfig({
24
+ ttlSec: {
25
+ ["local" /* LOCAL */]: ttlSec,
26
+ ["remote" /* REMOTE */]: ttlSec
27
+ },
28
+ ramp: {
29
+ ["local" /* LOCAL */]: 100,
30
+ ["remote" /* REMOTE */]: 100
31
+ }
32
+ });
33
+ }
34
+ };
35
+ function stablePercent(value) {
36
+ let hash = 2166136261;
37
+ for (let index = 0; index < value.length; index += 1) {
38
+ hash ^= value.charCodeAt(index);
39
+ hash = Math.imul(hash, 16777619);
40
+ }
41
+ return (hash >>> 0) / 4294967296 * 100;
42
+ }
43
+
44
+ // src/context.ts
45
+ import { AsyncLocalStorage } from "async_hooks";
46
+ var DialCacheContext = class {
47
+ storage = new AsyncLocalStorage();
48
+ isEnabled() {
49
+ return this.storage.getStore() ?? false;
50
+ }
51
+ enable(fn) {
52
+ return this.run(true, fn);
53
+ }
54
+ disable(fn) {
55
+ return this.run(false, fn);
56
+ }
57
+ async run(enabled, fn) {
58
+ return await this.storage.run(enabled, async () => await fn());
59
+ }
60
+ };
61
+
62
+ // src/metrics.ts
63
+ import { Counter, Histogram, register as defaultRegistry } from "prom-client";
64
+ var NO_CACHE_LAYER = "noop";
65
+ var TIMER_BUCKETS = [1e-3, 5e-3, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
66
+ var SIZE_BUCKETS = [100, 1e3, 1e4, 1e5, 1e6, 1e7];
67
+ var PrometheusDialCacheMetrics = class {
68
+ requestCounter;
69
+ missCounter;
70
+ disabledCounter;
71
+ errorCounter;
72
+ invalidationCounter;
73
+ coalescedCounter;
74
+ getTimer;
75
+ fallbackTimer;
76
+ serializationTimer;
77
+ sizeHistogram;
78
+ constructor(options = {}) {
79
+ const registry = options.registry ?? defaultRegistry;
80
+ const prefix = options.prefix ?? "";
81
+ this.disabledCounter = counter(registry, {
82
+ name: `${prefix}dialcache_disabled_counter`,
83
+ help: "Requests where DialCache skipped a cache layer.",
84
+ labelNames: ["use_case", "key_type", "layer", "reason"]
85
+ });
86
+ this.missCounter = counter(registry, {
87
+ name: `${prefix}dialcache_miss_counter`,
88
+ help: "DialCache cache misses.",
89
+ labelNames: ["use_case", "key_type", "layer"]
90
+ });
91
+ this.requestCounter = counter(registry, {
92
+ name: `${prefix}dialcache_request_counter`,
93
+ help: "Total DialCache cache-layer requests.",
94
+ labelNames: ["use_case", "key_type", "layer"]
95
+ });
96
+ this.errorCounter = counter(registry, {
97
+ name: `${prefix}dialcache_error_counter`,
98
+ help: "Errors during DialCache cache operations or fallback execution.",
99
+ labelNames: ["use_case", "key_type", "layer", "error", "in_fallback"]
100
+ });
101
+ this.invalidationCounter = counter(registry, {
102
+ name: `${prefix}dialcache_invalidation_counter`,
103
+ help: "DialCache invalidation calls by key type and layer.",
104
+ labelNames: ["key_type", "layer"]
105
+ });
106
+ this.coalescedCounter = counter(registry, {
107
+ name: `${prefix}dialcache_coalesced_counter`,
108
+ help: "DialCache requests coalesced onto an in-flight cache miss.",
109
+ labelNames: ["use_case", "key_type"]
110
+ });
111
+ this.getTimer = histogram(registry, {
112
+ name: `${prefix}dialcache_get_timer`,
113
+ help: "DialCache cache get latency in seconds.",
114
+ labelNames: ["use_case", "key_type", "layer"],
115
+ buckets: TIMER_BUCKETS
116
+ });
117
+ this.fallbackTimer = histogram(registry, {
118
+ name: `${prefix}dialcache_fallback_timer`,
119
+ help: "Time spent in the underlying fallback function in seconds.",
120
+ labelNames: ["use_case", "key_type", "layer"],
121
+ buckets: TIMER_BUCKETS
122
+ });
123
+ this.serializationTimer = histogram(registry, {
124
+ name: `${prefix}dialcache_serialization_timer`,
125
+ help: "DialCache serialization latency in seconds.",
126
+ labelNames: ["use_case", "key_type", "layer", "operation"],
127
+ buckets: TIMER_BUCKETS
128
+ });
129
+ this.sizeHistogram = histogram(registry, {
130
+ name: `${prefix}dialcache_size_histogram`,
131
+ help: "Serialized DialCache value sizes in bytes.",
132
+ labelNames: ["use_case", "key_type", "layer"],
133
+ buckets: SIZE_BUCKETS
134
+ });
135
+ }
136
+ request(labels) {
137
+ this.requestCounter.inc(cacheLabels(labels));
138
+ }
139
+ miss(labels) {
140
+ this.missCounter.inc(cacheLabels(labels));
141
+ }
142
+ disabled(labels) {
143
+ this.disabledCounter.inc({ ...cacheLabels(labels), reason: labels.reason });
144
+ }
145
+ error(labels) {
146
+ this.errorCounter.inc({
147
+ ...cacheLabels(labels),
148
+ error: labels.error,
149
+ in_fallback: String(labels.inFallback)
150
+ });
151
+ }
152
+ invalidation(labels) {
153
+ this.invalidationCounter.inc({ key_type: labels.keyType, layer: labels.layer });
154
+ }
155
+ coalesced(labels) {
156
+ this.coalescedCounter.inc({ use_case: labels.useCase, key_type: labels.keyType });
157
+ }
158
+ observeGet(labels, seconds) {
159
+ this.getTimer.observe(cacheLabels(labels), seconds);
160
+ }
161
+ observeFallback(labels, seconds) {
162
+ this.fallbackTimer.observe(cacheLabels(labels), seconds);
163
+ }
164
+ observeSerialization(labels, seconds) {
165
+ this.serializationTimer.observe({ ...cacheLabels(labels), operation: labels.operation }, seconds);
166
+ }
167
+ observeSize(labels, bytes) {
168
+ this.sizeHistogram.observe(cacheLabels(labels), bytes);
169
+ }
170
+ };
171
+ function createPrometheusDialCacheMetrics(options = {}) {
172
+ return new PrometheusDialCacheMetrics(options);
173
+ }
174
+ function labelsFor(key, layer) {
175
+ return { useCase: key.useCase, keyType: key.keyType, layer };
176
+ }
177
+ function errorName(error) {
178
+ return error instanceof Error ? error.name : typeof error;
179
+ }
180
+ function cacheLabels(labels) {
181
+ return {
182
+ use_case: labels.useCase,
183
+ key_type: labels.keyType,
184
+ layer: labels.layer
185
+ };
186
+ }
187
+ function counter(registry, config) {
188
+ return registry.getSingleMetric(config.name) ?? new Counter({ ...config, registers: [registry] });
189
+ }
190
+ function histogram(registry, config) {
191
+ return registry.getSingleMetric(config.name) ?? new Histogram({ ...config, buckets: [...config.buckets], registers: [registry] });
192
+ }
193
+
194
+ // src/errors.ts
195
+ var DialCacheError = class extends Error {
196
+ constructor(message) {
197
+ super(message);
198
+ this.name = new.target.name;
199
+ }
200
+ };
201
+ var UseCaseIsAlreadyRegisteredError = class extends DialCacheError {
202
+ constructor(useCase) {
203
+ super(`Use case already registered: ${useCase}`);
204
+ }
205
+ };
206
+ var UseCaseNameIsReservedError = class extends DialCacheError {
207
+ constructor(useCase) {
208
+ super(`Use case name is reserved: ${useCase}`);
209
+ }
210
+ };
211
+ var MissingKeyConfigError = class extends DialCacheError {
212
+ constructor(useCase) {
213
+ super(`Missing key config for use case: ${useCase}`);
214
+ }
215
+ };
216
+
217
+ // src/dialcache.ts
218
+ import { performance as performance3 } from "perf_hooks";
219
+
220
+ // src/key.ts
221
+ var DialCacheKey = class {
222
+ keyType;
223
+ id;
224
+ useCase;
225
+ args;
226
+ urnPrefix;
227
+ prefix;
228
+ urn;
229
+ defaultConfig;
230
+ serializer;
231
+ trackForInvalidation;
232
+ constructor(init) {
233
+ this.keyType = init.keyType;
234
+ this.id = init.id;
235
+ this.useCase = init.useCase;
236
+ this.args = init.args ?? [];
237
+ this.defaultConfig = init.defaultConfig ?? null;
238
+ this.serializer = init.serializer ?? null;
239
+ this.trackForInvalidation = init.trackForInvalidation ?? false;
240
+ this.urnPrefix = init.urnPrefix ?? "urn";
241
+ const rawPrefix = joinUrnComponents(this.urnPrefix, this.keyType, this.id);
242
+ this.prefix = this.trackForInvalidation ? redisClusterHashTag(invalidationPrefix(this.urnPrefix, this.keyType, this.id)) : rawPrefix;
243
+ const args = this.args.length > 0 ? `?${this.args.map(([name, value]) => `${encodeComponent(name)}=${encodeComponent(value)}`).join("&")}` : "";
244
+ this.urn = `${this.prefix}${args}#${encodeComponent(this.useCase)}`;
245
+ }
246
+ toString() {
247
+ return this.urn;
248
+ }
249
+ };
250
+ function normalizeArgs(args) {
251
+ return Object.entries(args).filter(([, value]) => value !== void 0).map(([name, value]) => [name, String(value)]).sort(([left], [right]) => compareCodePoints(left, right));
252
+ }
253
+ function invalidationPrefix(urnPrefix, keyType, id) {
254
+ assertRedisHashTagComponent("urnPrefix", urnPrefix);
255
+ assertRedisHashTagComponent("keyType", keyType);
256
+ assertRedisHashTagComponent("id", id);
257
+ return joinUrnComponents(urnPrefix, keyType, id);
258
+ }
259
+ function redisClusterHashTag(value) {
260
+ assertRedisHashTagComponent("value", value);
261
+ return `{${value}}`;
262
+ }
263
+ function assertRedisHashTagComponent(name, value) {
264
+ if (value.includes("{") || value.includes("}")) {
265
+ throw new Error(`Redis Cluster hash tag components must not contain braces: ${name}`);
266
+ }
267
+ }
268
+ function joinUrnComponents(...components) {
269
+ return components.map(encodeComponent).join(":");
270
+ }
271
+ function encodeComponent(value) {
272
+ return encodeURIComponent(value);
273
+ }
274
+ function compareCodePoints(left, right) {
275
+ return left < right ? -1 : left > right ? 1 : 0;
276
+ }
277
+
278
+ // src/internal/local-cache.ts
279
+ import { performance } from "perf_hooks";
280
+ import { LRUCache } from "lru-cache";
281
+
282
+ // src/internal/runtime-config.ts
283
+ async function fetchKeyConfig(configProvider, key) {
284
+ return await configProvider(key) ?? key.defaultConfig;
285
+ }
286
+ async function resolveLayerConfigResult(options) {
287
+ const config = options.config;
288
+ if (config === null) {
289
+ return { status: "disabled", reason: "missing_config" };
290
+ }
291
+ const ttlSec = config.ttlSec[options.layer];
292
+ if (ttlSec === void 0) {
293
+ return { status: "disabled", reason: "missing_config" };
294
+ }
295
+ if (!Number.isSafeInteger(ttlSec) || ttlSec <= 0) {
296
+ return { status: "disabled", reason: "invalid_ttl" };
297
+ }
298
+ const configuredRamp = config.ramp[options.layer];
299
+ if (configuredRamp === void 0) {
300
+ return { status: "disabled", reason: "missing_config" };
301
+ }
302
+ if (!Number.isFinite(configuredRamp)) {
303
+ return { status: "disabled", reason: "ramped_down" };
304
+ }
305
+ const ramp = clampPercentage(configuredRamp);
306
+ if (ramp <= 0) {
307
+ return { status: "disabled", reason: "ramped_down" };
308
+ }
309
+ if (ramp >= 100) {
310
+ return { status: "enabled", config: { ttlSec, ramp } };
311
+ }
312
+ const sample = await options.rampSampler({ key: options.key, layer: options.layer, ramp });
313
+ if (!Number.isFinite(sample)) {
314
+ return { status: "disabled", reason: "ramped_down" };
315
+ }
316
+ return clampPercentage(sample) < ramp ? { status: "enabled", config: { ttlSec, ramp } } : { status: "disabled", reason: "ramped_down" };
317
+ }
318
+ function clampPercentage(value) {
319
+ if (value <= 0) {
320
+ return 0;
321
+ }
322
+ if (value >= 100) {
323
+ return 100;
324
+ }
325
+ return value;
326
+ }
327
+
328
+ // src/internal/local-cache.ts
329
+ var LocalCache = class {
330
+ constructor(configProvider, rampSampler, maxSize) {
331
+ this.configProvider = configProvider;
332
+ this.rampSampler = rampSampler;
333
+ this.cache = maxSize === 0 ? null : new LRUCache({
334
+ // Weight every entry as one so large configured limits remain sparse
335
+ // instead of eagerly preallocating max-sized storage arrays.
336
+ maxSize,
337
+ // Read a fresh monotonic integer clock and avoid lru-cache's zero
338
+ // timestamp sentinel when the process or a fake clock starts at 0.
339
+ perf: { now: () => Math.floor(performance.now()) + 1 },
340
+ ttlResolution: 0
341
+ });
342
+ }
343
+ configProvider;
344
+ rampSampler;
345
+ cache;
346
+ async get(key, fallback) {
347
+ const result = await this.getIfPresentResult(key);
348
+ if (result.status === "hit") {
349
+ return result.value;
350
+ }
351
+ const value = await fallback();
352
+ if (result.status === "miss") {
353
+ await this.put(key, value, result.config);
354
+ }
355
+ return value;
356
+ }
357
+ async getIfPresent(key) {
358
+ const result = await this.getIfPresentResult(key);
359
+ return result.status === "hit" ? result.value : void 0;
360
+ }
361
+ async getIfPresentResult(key, keyConfig) {
362
+ const layerConfig = await this.resolveLocalLayerConfig(key, keyConfig);
363
+ if (layerConfig.status === "disabled") {
364
+ return layerConfig;
365
+ }
366
+ const hit = this.cache?.get(key.urn);
367
+ if (hit !== void 0) {
368
+ return { status: "hit", value: hit.value };
369
+ }
370
+ return { status: "miss", config: layerConfig.config };
371
+ }
372
+ async put(key, value, config) {
373
+ const ttlSec = config?.ttlSec ?? await this.resolveLocalTtlSec(key);
374
+ if (ttlSec === null) {
375
+ return;
376
+ }
377
+ const ttlMs = ttlSec * 1e3 - 1;
378
+ this.cache?.set(key.urn, { value }, { size: 1, ttl: ttlMs });
379
+ }
380
+ async flushAll() {
381
+ this.cache?.clear();
382
+ }
383
+ async resolveLocalLayerConfig(key, keyConfig) {
384
+ const config = keyConfig === void 0 ? await fetchKeyConfig(this.configProvider, key) : keyConfig;
385
+ return await resolveLayerConfigResult({
386
+ config,
387
+ key,
388
+ layer: "local" /* LOCAL */,
389
+ rampSampler: this.rampSampler
390
+ });
391
+ }
392
+ async resolveLocalTtlSec(key) {
393
+ const layerConfig = await this.resolveLocalLayerConfig(key);
394
+ return layerConfig.status === "enabled" ? layerConfig.config.ttlSec : null;
395
+ }
396
+ };
397
+
398
+ // src/internal/redis-cache.ts
399
+ import { performance as performance2 } from "perf_hooks";
400
+
401
+ // src/serializer.ts
402
+ var JSON_UNDEFINED_SENTINEL = "__dialcache_json_undefined_v1__";
403
+ var JsonSerializer = class {
404
+ async dump(value) {
405
+ if (value === void 0) {
406
+ return JSON_UNDEFINED_SENTINEL;
407
+ }
408
+ const payload = JSON.stringify(value);
409
+ if (payload === void 0) {
410
+ throw new Error("DialCache JSON serializer cannot serialize this value");
411
+ }
412
+ return payload;
413
+ }
414
+ async load(value) {
415
+ const payload = Buffer.isBuffer(value) ? value.toString("utf8") : value;
416
+ if (payload === JSON_UNDEFINED_SENTINEL) {
417
+ return void 0;
418
+ }
419
+ return JSON.parse(payload);
420
+ }
421
+ };
422
+
423
+ // src/internal/redis-cache.ts
424
+ var defaultSerializer = new JsonSerializer();
425
+ var REDIS_FRAME_KEY_SUFFIX = ":dialcache-frame-v1";
426
+ var RedisCache = class {
427
+ configProvider;
428
+ rampSampler;
429
+ keyPrefix;
430
+ defaultSerializer;
431
+ watermarkTtlMs;
432
+ createClient;
433
+ metrics;
434
+ clientPromise;
435
+ constructor(options) {
436
+ this.configProvider = options.configProvider;
437
+ this.rampSampler = options.rampSampler;
438
+ this.keyPrefix = options.redis.keyPrefix ?? "";
439
+ this.defaultSerializer = options.redis.serializer ?? defaultSerializer;
440
+ const watermarkTtlSec = options.redis.watermarkTtlSec ?? DEFAULT_WATERMARK_TTL_SEC;
441
+ if (!Number.isSafeInteger(watermarkTtlSec) || watermarkTtlSec <= 0) {
442
+ throw new RangeError("Redis watermarkTtlSec must be a positive safe integer");
443
+ }
444
+ this.watermarkTtlMs = watermarkTtlSec * 1e3;
445
+ if (!Number.isSafeInteger(this.watermarkTtlMs)) {
446
+ throw new RangeError("Redis watermarkTtlSec is too large");
447
+ }
448
+ this.metrics = options.metrics;
449
+ if (options.redis.client === void 0 && options.redis.createClient === void 0) {
450
+ throw new Error("Redis config requires either client or createClient");
451
+ }
452
+ this.createClient = options.redis.createClient ?? null;
453
+ this.clientPromise = options.redis.client === void 0 ? null : Promise.resolve(options.redis.client);
454
+ }
455
+ async get(key) {
456
+ const result = await this.getResult(key);
457
+ return result.status === "hit" ? result.value : void 0;
458
+ }
459
+ async getResult(key, keyConfig) {
460
+ const layerConfig = await this.resolveRemoteLayerConfig(key, keyConfig);
461
+ if (layerConfig.status === "disabled") {
462
+ return layerConfig;
463
+ }
464
+ return await this.getWithResolvedConfig(key, layerConfig.config);
465
+ }
466
+ async getWithResolvedConfig(key, layerConfig) {
467
+ const client = await this.resolveClient();
468
+ const redisKey = this.redisKey(key);
469
+ const payload = await client.read({
470
+ valueKey: redisKey,
471
+ ...key.trackForInvalidation ? { watermarkKey: this.redisWatermarkKeyFromKey(key) } : {}
472
+ });
473
+ if (payload === null) {
474
+ return { status: "miss", config: layerConfig };
475
+ }
476
+ const start = performance2.now();
477
+ try {
478
+ const value = await this.serializerFor(key).load(payload);
479
+ return { status: "hit", value };
480
+ } catch (error) {
481
+ this.recordMetric((metrics) => metrics.error({ ...labelsFor(key, "remote" /* REMOTE */), error: errorName(error), inFallback: false }));
482
+ return { status: "miss", config: layerConfig };
483
+ } finally {
484
+ this.recordMetric((metrics) => metrics.observeSerialization({ ...labelsFor(key, "remote" /* REMOTE */), operation: "load" }, elapsedSeconds(start)));
485
+ }
486
+ }
487
+ async put(key, value, config) {
488
+ const ttlSec = config?.ttlSec ?? await this.resolveRemoteTtlSec(key);
489
+ if (ttlSec === null) {
490
+ return true;
491
+ }
492
+ const start = performance2.now();
493
+ let serialized;
494
+ try {
495
+ serialized = await this.serializerFor(key).dump(value);
496
+ } finally {
497
+ this.recordMetric((metrics) => metrics.observeSerialization({ ...labelsFor(key, "remote" /* REMOTE */), operation: "dump" }, elapsedSeconds(start)));
498
+ }
499
+ this.recordMetric((metrics) => metrics.observeSize(labelsFor(key, "remote" /* REMOTE */), payloadSize(serialized)));
500
+ const client = await this.resolveClient();
501
+ const cacheTtlMs = ttlSec * 1e3;
502
+ if (!Number.isSafeInteger(cacheTtlMs)) {
503
+ throw new RangeError("Redis cache TTL is too large");
504
+ }
505
+ const request = {
506
+ valueKey: this.redisKey(key),
507
+ cacheTtlMs,
508
+ value: serialized
509
+ };
510
+ return key.trackForInvalidation ? await client.write({
511
+ ...request,
512
+ watermarkKey: this.redisWatermarkKeyFromKey(key),
513
+ watermarkTtlFloorMs: this.watermarkTtlMs
514
+ }) : await client.write(request);
515
+ }
516
+ async invalidate(keyType, id, futureBufferMs = 0, urnPrefix = "urn") {
517
+ const client = await this.resolveClient();
518
+ await client.invalidate({
519
+ watermarkKey: this.redisWatermarkKey(urnPrefix, keyType, id),
520
+ futureBufferMs,
521
+ watermarkTtlFloorMs: this.watermarkTtlMs
522
+ });
523
+ }
524
+ async flushAll() {
525
+ const client = await this.resolveClient();
526
+ await client.flushAll();
527
+ }
528
+ redisKey(key) {
529
+ return `${this.keyPrefix}${key.urn}${REDIS_FRAME_KEY_SUFFIX}`;
530
+ }
531
+ redisWatermarkKey(urnPrefix, keyType, id) {
532
+ return `${this.keyPrefix}${redisClusterHashTag(invalidationPrefix(urnPrefix, keyType, id))}#watermark`;
533
+ }
534
+ redisWatermarkKeyFromKey(key) {
535
+ return this.redisWatermarkKey(key.urnPrefix, key.keyType, key.id);
536
+ }
537
+ async resolveClient() {
538
+ if (this.clientPromise === null) {
539
+ if (this.createClient === null) {
540
+ throw new Error("Redis client has not been configured");
541
+ }
542
+ this.clientPromise = Promise.resolve(this.createClient());
543
+ }
544
+ try {
545
+ return await this.clientPromise;
546
+ } catch (error) {
547
+ if (this.createClient !== null) {
548
+ this.clientPromise = null;
549
+ }
550
+ throw error;
551
+ }
552
+ }
553
+ serializerFor(key) {
554
+ return key.serializer ?? this.defaultSerializer;
555
+ }
556
+ async resolveRemoteLayerConfig(key, keyConfig) {
557
+ const config = keyConfig === void 0 ? await fetchKeyConfig(this.configProvider, key) : keyConfig;
558
+ return await resolveLayerConfigResult({
559
+ config,
560
+ key,
561
+ layer: "remote" /* REMOTE */,
562
+ rampSampler: this.rampSampler
563
+ });
564
+ }
565
+ async resolveRemoteTtlSec(key) {
566
+ const layerConfig = await this.resolveRemoteLayerConfig(key);
567
+ return layerConfig.status === "enabled" ? layerConfig.config.ttlSec : null;
568
+ }
569
+ recordMetric(record) {
570
+ if (this.metrics === null) {
571
+ return;
572
+ }
573
+ try {
574
+ record(this.metrics);
575
+ } catch {
576
+ }
577
+ }
578
+ };
579
+ function payloadSize(payload) {
580
+ return Buffer.isBuffer(payload) ? payload.byteLength : Buffer.byteLength(payload);
581
+ }
582
+ function elapsedSeconds(startMs) {
583
+ return Math.max((performance2.now() - startMs) / 1e3, 0);
584
+ }
585
+
586
+ // src/dialcache.ts
587
+ var DEFAULT_LOCAL_MAX_SIZE = 1e4;
588
+ var defaultConfigProvider = () => null;
589
+ var defaultLogger = console;
590
+ var DialCache = class {
591
+ context = new DialCacheContext();
592
+ localCache;
593
+ useCases = /* @__PURE__ */ new Set();
594
+ configProvider;
595
+ urnPrefix;
596
+ logger;
597
+ rampSampler;
598
+ redisCache;
599
+ metrics;
600
+ inFlight = /* @__PURE__ */ new Map();
601
+ constructor(config = {}) {
602
+ const localMaxSize = config.localMaxSize ?? DEFAULT_LOCAL_MAX_SIZE;
603
+ if (!Number.isSafeInteger(localMaxSize) || localMaxSize < 0) {
604
+ throw new RangeError("DialCache localMaxSize must be a nonnegative safe integer");
605
+ }
606
+ this.configProvider = config.cacheConfigProvider ?? defaultConfigProvider;
607
+ this.urnPrefix = config.urnPrefix ?? "urn";
608
+ this.logger = config.logger ?? defaultLogger;
609
+ this.rampSampler = config.rampSampler ?? deterministicRampSampler;
610
+ const metrics = config.metrics === false ? null : config.metrics ?? createPrometheusDialCacheMetrics({
611
+ prefix: config.metricsPrefix ?? "",
612
+ ...config.metricsRegistry === void 0 ? {} : { registry: config.metricsRegistry }
613
+ });
614
+ this.metrics = safeMetrics(metrics);
615
+ this.localCache = new LocalCache(this.configProvider, this.rampSampler, localMaxSize);
616
+ this.redisCache = config.redis === void 0 ? null : new RedisCache({
617
+ configProvider: this.configProvider,
618
+ rampSampler: this.rampSampler,
619
+ redis: config.redis,
620
+ metrics: this.metrics
621
+ });
622
+ }
623
+ enable(fn) {
624
+ return this.context.enable(fn);
625
+ }
626
+ disable(fn) {
627
+ return this.context.disable(fn);
628
+ }
629
+ withEnabled(fn) {
630
+ return this.enable(fn);
631
+ }
632
+ withDisabled(fn) {
633
+ return this.disable(fn);
634
+ }
635
+ isEnabled() {
636
+ return this.context.isEnabled();
637
+ }
638
+ cached(fn, options) {
639
+ this.registerUseCase(options.useCase);
640
+ const run = async (...args) => {
641
+ const fallback = async () => await fn(...args);
642
+ const noLayerLabels = { useCase: options.useCase, keyType: options.keyType, layer: NO_CACHE_LAYER };
643
+ if (!this.isEnabled()) {
644
+ this.metrics?.disabled({ ...noLayerLabels, reason: "context" });
645
+ return await fallback();
646
+ }
647
+ let key;
648
+ try {
649
+ key = this.buildKey(options, options.cacheKey(...args));
650
+ } catch (error) {
651
+ this.logger.error("Could not construct DialCache key", error);
652
+ this.metrics?.error({
653
+ ...noLayerLabels,
654
+ error: errorName(error),
655
+ inFallback: false
656
+ });
657
+ return await this.callFallback(noLayerLabels, fallback);
658
+ }
659
+ let keyConfig;
660
+ try {
661
+ keyConfig = await fetchKeyConfig(this.configProvider, key);
662
+ } catch (error) {
663
+ this.logger.warn("Could not resolve DialCache key config", error);
664
+ this.metrics?.disabled({ ...noLayerLabels, reason: "config_error" });
665
+ return await this.callFallback(noLayerLabels, fallback);
666
+ }
667
+ const redisCache = this.redisCache;
668
+ return redisCache === null ? await this.getThroughLocalOnly(key, keyConfig, fallback) : await this.getThroughRedisChain(redisCache, key, keyConfig, fallback);
669
+ };
670
+ return run;
671
+ }
672
+ /**
673
+ * Writes a remote invalidation watermark for Redis-tracked entries.
674
+ *
675
+ * This does not synchronously evict local cache hits or untracked Redis values.
676
+ *
677
+ * @param futureBufferMs Nonnegative safe integer covering source lag and stale fallback work through the Redis write.
678
+ */
679
+ async invalidateRemote(keyType, id, futureBufferMs = 0) {
680
+ assertValidFutureBufferMs(futureBufferMs);
681
+ if (this.redisCache === null) {
682
+ return;
683
+ }
684
+ this.metrics?.invalidation({ keyType, layer: "remote" /* REMOTE */ });
685
+ try {
686
+ await this.redisCache.invalidate(keyType, String(id), futureBufferMs, this.urnPrefix);
687
+ } catch (error) {
688
+ this.logger.warn("Error writing DialCache invalidation watermark", error);
689
+ this.metrics?.error({
690
+ useCase: "watermark",
691
+ keyType,
692
+ layer: "remote" /* REMOTE */,
693
+ error: errorName(error),
694
+ inFallback: false
695
+ });
696
+ throw error;
697
+ }
698
+ }
699
+ async flushAll() {
700
+ await this.localCache.flushAll();
701
+ if (this.redisCache === null) {
702
+ return;
703
+ }
704
+ try {
705
+ await this.redisCache.flushAll();
706
+ } catch (error) {
707
+ this.logger.warn("Error flushing Redis cache", error);
708
+ this.metrics?.error({
709
+ useCase: "flushAll",
710
+ keyType: "all",
711
+ layer: "remote" /* REMOTE */,
712
+ error: errorName(error),
713
+ inFallback: false
714
+ });
715
+ throw error;
716
+ }
717
+ }
718
+ async getThroughLocalOnly(key, keyConfig, fallback) {
719
+ const local = await this.readLocal(key, keyConfig);
720
+ if (local.status === "hit") {
721
+ return local.value;
722
+ }
723
+ const runFallback = async () => {
724
+ const value = await this.callFallback(labelsFor(key, "local" /* LOCAL */), fallback);
725
+ if (local.status === "miss") {
726
+ await this.putLocalFailOpen(key, value, local.config);
727
+ }
728
+ return value;
729
+ };
730
+ return local.status === "miss" ? await this.singleFlight(key, runFallback) : await runFallback();
731
+ }
732
+ async getThroughRedisChain(redisCache, key, keyConfig, fallback) {
733
+ const local = await this.readLocal(key, keyConfig);
734
+ if (local.status === "hit") {
735
+ return local.value;
736
+ }
737
+ if (local.status === "miss") {
738
+ return await this.singleFlight(key, async () => await this.getThroughRemoteAfterLocal(redisCache, key, local, keyConfig, fallback));
739
+ }
740
+ const remoteLayer = await this.resolveRemoteLayerConfig(key, keyConfig);
741
+ if (remoteLayer.status === "disabled") {
742
+ return await this.finishRedisChain(redisCache, key, local, remoteLayer, fallback);
743
+ }
744
+ return await this.singleFlight(key, async () => {
745
+ const remote = await this.readRemoteWithResolvedConfig(redisCache, key, remoteLayer.config);
746
+ return await this.finishRedisChain(redisCache, key, local, remote, fallback);
747
+ });
748
+ }
749
+ async getThroughRemoteAfterLocal(redisCache, key, local, keyConfig, fallback) {
750
+ const remote = await this.readRemote(redisCache, key, keyConfig);
751
+ return await this.finishRedisChain(redisCache, key, local, remote, fallback);
752
+ }
753
+ async finishRedisChain(redisCache, key, local, remote, fallback) {
754
+ if (remote.status === "hit") {
755
+ if (local.status === "miss") {
756
+ await this.putLocalFailOpen(key, remote.value, local.config);
757
+ }
758
+ return remote.value;
759
+ }
760
+ const remoteErrored = remote.status === "disabled" && remote.reason === "config_error";
761
+ const runFallback = async () => {
762
+ const fallbackLayer = remote.status === "miss" || remoteErrored ? "remote" /* REMOTE */ : "local" /* LOCAL */;
763
+ const value = await this.callFallback(labelsFor(key, fallbackLayer), fallback);
764
+ const skipCacheWrite = (remote.status === "miss" || remote.status === "disabled") && remote.skipCacheWrite === true;
765
+ let suppressCacheWrite = skipCacheWrite;
766
+ if (!suppressCacheWrite && (remote.status === "miss" || remoteErrored)) {
767
+ try {
768
+ const wroteRemote = await redisCache.put(key, value, remote.status === "miss" ? remote.config : void 0);
769
+ suppressCacheWrite = wroteRemote === false;
770
+ } catch (error) {
771
+ this.logger.warn("Error putting value in Redis cache", error);
772
+ this.recordError(key, "remote" /* REMOTE */, error, false);
773
+ suppressCacheWrite = key.trackForInvalidation;
774
+ }
775
+ }
776
+ if (!suppressCacheWrite && local.status === "miss") {
777
+ await this.putLocalFailOpen(key, value, local.config);
778
+ }
779
+ return value;
780
+ };
781
+ return await runFallback();
782
+ }
783
+ async readLocal(key, keyConfig) {
784
+ const start = performance3.now();
785
+ try {
786
+ const result = await this.localCache.getIfPresentResult(key, keyConfig);
787
+ if (result.status === "disabled") {
788
+ this.metrics?.disabled({ ...labelsFor(key, "local" /* LOCAL */), reason: result.reason });
789
+ return result;
790
+ }
791
+ this.metrics?.request(labelsFor(key, "local" /* LOCAL */));
792
+ this.metrics?.observeGet(labelsFor(key, "local" /* LOCAL */), elapsedSeconds2(start));
793
+ if (result.status === "miss") {
794
+ this.metrics?.miss(labelsFor(key, "local" /* LOCAL */));
795
+ }
796
+ return result;
797
+ } catch (error) {
798
+ this.logger.error("Error getting value from local cache", error);
799
+ this.recordError(key, "local" /* LOCAL */, error, false);
800
+ this.metrics?.disabled({ ...labelsFor(key, "local" /* LOCAL */), reason: "config_error" });
801
+ return { status: "disabled", reason: "config_error" };
802
+ }
803
+ }
804
+ async resolveRemoteLayerConfig(key, keyConfig) {
805
+ try {
806
+ const result = await resolveLayerConfigResult({
807
+ config: keyConfig,
808
+ key,
809
+ layer: "remote" /* REMOTE */,
810
+ rampSampler: this.rampSampler
811
+ });
812
+ if (result.status === "disabled") {
813
+ this.metrics?.disabled({ ...labelsFor(key, "remote" /* REMOTE */), reason: result.reason });
814
+ }
815
+ return result;
816
+ } catch (error) {
817
+ this.logger.warn("Error resolving Redis cache config", error);
818
+ this.recordError(key, "remote" /* REMOTE */, error, false);
819
+ return { status: "disabled", reason: "config_error", ...key.trackForInvalidation ? { skipCacheWrite: true } : {} };
820
+ }
821
+ }
822
+ async readRemote(redisCache, key, keyConfig) {
823
+ const layerConfig = await this.resolveRemoteLayerConfig(key, keyConfig);
824
+ if (layerConfig.status === "disabled") {
825
+ return layerConfig;
826
+ }
827
+ return await this.readRemoteWithResolvedConfig(redisCache, key, layerConfig.config);
828
+ }
829
+ async readRemoteWithResolvedConfig(redisCache, key, layerConfig) {
830
+ const start = performance3.now();
831
+ try {
832
+ const result = await redisCache.getWithResolvedConfig(key, layerConfig);
833
+ this.metrics?.request(labelsFor(key, "remote" /* REMOTE */));
834
+ this.metrics?.observeGet(labelsFor(key, "remote" /* REMOTE */), elapsedSeconds2(start));
835
+ if (result.status === "miss") {
836
+ this.metrics?.miss(labelsFor(key, "remote" /* REMOTE */));
837
+ }
838
+ return result;
839
+ } catch (error) {
840
+ this.logger.warn("Error getting value from Redis cache", error);
841
+ this.recordError(key, "remote" /* REMOTE */, error, false);
842
+ return { status: "disabled", reason: "config_error", ...key.trackForInvalidation ? { skipCacheWrite: true } : {} };
843
+ }
844
+ }
845
+ async putLocalFailOpen(key, value, config) {
846
+ try {
847
+ await this.localCache.put(key, value, config);
848
+ } catch (error) {
849
+ this.logger.warn("Error putting value in local cache", error);
850
+ this.recordError(key, "local" /* LOCAL */, error, false);
851
+ }
852
+ }
853
+ async callFallback(labels, fallback) {
854
+ const start = performance3.now();
855
+ try {
856
+ return await fallback();
857
+ } catch (error) {
858
+ this.metrics?.error({ ...labels, error: errorName(error), inFallback: true });
859
+ throw error;
860
+ } finally {
861
+ this.metrics?.observeFallback(labels, elapsedSeconds2(start));
862
+ }
863
+ }
864
+ recordError(key, layer, error, inFallback) {
865
+ this.metrics?.error({ ...labelsFor(key, layer), error: errorName(error), inFallback });
866
+ }
867
+ registerUseCase(useCase) {
868
+ if (useCase === "watermark") {
869
+ throw new UseCaseNameIsReservedError(useCase);
870
+ }
871
+ if (this.useCases.has(useCase)) {
872
+ throw new UseCaseIsAlreadyRegisteredError(useCase);
873
+ }
874
+ this.useCases.add(useCase);
875
+ }
876
+ buildKey(options, cacheKey) {
877
+ const spec = typeof cacheKey === "object" ? cacheKey : { id: cacheKey };
878
+ return new DialCacheKey({
879
+ keyType: options.keyType,
880
+ id: String(spec.id),
881
+ useCase: options.useCase,
882
+ args: normalizeArgs(spec.args ?? {}),
883
+ urnPrefix: this.urnPrefix,
884
+ defaultConfig: options.defaultConfig ?? null,
885
+ serializer: options.serializer ?? null,
886
+ trackForInvalidation: options.trackForInvalidation ?? false
887
+ });
888
+ }
889
+ singleFlight(key, run) {
890
+ const existing = this.inFlight.get(key.urn);
891
+ if (existing !== void 0) {
892
+ this.metrics?.coalesced?.({ useCase: key.useCase, keyType: key.keyType });
893
+ return existing;
894
+ }
895
+ const promise = run();
896
+ this.inFlight.set(key.urn, promise);
897
+ const clear = () => {
898
+ this.inFlight.delete(key.urn);
899
+ };
900
+ void promise.then(clear, clear);
901
+ return promise;
902
+ }
903
+ };
904
+ function elapsedSeconds2(startMs) {
905
+ return Math.max((performance3.now() - startMs) / 1e3, 0);
906
+ }
907
+ function assertValidFutureBufferMs(futureBufferMs) {
908
+ if (!Number.isSafeInteger(futureBufferMs) || futureBufferMs < 0) {
909
+ throw new RangeError("DialCache invalidation futureBufferMs must be a nonnegative safe integer");
910
+ }
911
+ }
912
+ function safeMetrics(metrics) {
913
+ if (metrics === null) {
914
+ return null;
915
+ }
916
+ return {
917
+ request: (labels) => callMetric(() => metrics.request(labels)),
918
+ miss: (labels) => callMetric(() => metrics.miss(labels)),
919
+ disabled: (labels) => callMetric(() => metrics.disabled(labels)),
920
+ error: (labels) => callMetric(() => metrics.error(labels)),
921
+ invalidation: (labels) => callMetric(() => metrics.invalidation(labels)),
922
+ coalesced: (labels) => callMetric(() => metrics.coalesced?.(labels)),
923
+ observeGet: (labels, seconds) => callMetric(() => metrics.observeGet(labels, seconds)),
924
+ observeFallback: (labels, seconds) => callMetric(() => metrics.observeFallback(labels, seconds)),
925
+ observeSerialization: (labels, seconds) => callMetric(() => metrics.observeSerialization(labels, seconds)),
926
+ observeSize: (labels, bytes) => callMetric(() => metrics.observeSize(labels, bytes))
927
+ };
928
+ }
929
+ function callMetric(record) {
930
+ try {
931
+ record();
932
+ } catch {
933
+ }
934
+ }
935
+ export {
936
+ CacheLayer,
937
+ DEFAULT_WATERMARK_TTL_SEC,
938
+ DialCache,
939
+ DialCacheContext,
940
+ DialCacheError,
941
+ DialCacheKey,
942
+ DialCacheKeyConfig,
943
+ DialCacheRedisPayloadEncodingError,
944
+ DialCacheRedisPayloadError,
945
+ JsonSerializer,
946
+ MissingKeyConfigError,
947
+ PrometheusDialCacheMetrics,
948
+ UseCaseIsAlreadyRegisteredError,
949
+ UseCaseNameIsReservedError,
950
+ createPrometheusDialCacheMetrics,
951
+ deterministicRampSampler,
952
+ invalidationPrefix,
953
+ normalizeArgs,
954
+ randomRampSampler,
955
+ redisClusterHashTag
956
+ };