layercache 1.2.8 → 1.3.0

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.
@@ -688,7 +688,7 @@ function normalizeForSerialization(value) {
688
688
  }
689
689
  function serializeKeyPart(value) {
690
690
  if (typeof value === "string") {
691
- return `s:${value}`;
691
+ return `s:${value.replace(/%/g, "%25").replace(/:/g, "%3A")}`;
692
692
  }
693
693
  if (typeof value === "number") {
694
694
  return `n:${value}`;
@@ -1077,6 +1077,7 @@ var CacheStackLayerWriter = class {
1077
1077
  }
1078
1078
  const results = await Promise.allSettled(operations.map((operation) => operation()));
1079
1079
  const failures = results.filter((result) => result.status === "rejected");
1080
+ const degraded = results.filter((result) => result.status === "fulfilled");
1080
1081
  if (failures.length === 0) {
1081
1082
  return;
1082
1083
  }
@@ -1255,6 +1256,7 @@ function planFreshReadPolicies({
1255
1256
  }
1256
1257
 
1257
1258
  // ../../src/internal/CacheStackSnapshotManager.ts
1259
+ import { randomBytes } from "crypto";
1258
1260
  import { constants, promises as fs } from "fs";
1259
1261
  import path from "path";
1260
1262
 
@@ -1354,6 +1356,42 @@ async function readUtf8HandleWithLimit(handle, byteLimit) {
1354
1356
  return Buffer.concat(chunks).toString("utf8");
1355
1357
  }
1356
1358
 
1359
+ // ../../src/internal/StructuredDataSanitizer.ts
1360
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1361
+ function sanitizeStructuredData(value, options) {
1362
+ return sanitizeValue(value, 0, { count: 0 }, options);
1363
+ }
1364
+ function sanitizeValue(value, depth, state, options) {
1365
+ state.count += 1;
1366
+ if (state.count > options.maxNodes) {
1367
+ throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
1368
+ }
1369
+ if (depth > options.maxDepth) {
1370
+ throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
1371
+ }
1372
+ if (Array.isArray(value)) {
1373
+ const sanitized2 = [];
1374
+ for (const entry of value) {
1375
+ sanitized2.push(sanitizeValue(entry, depth + 1, state, options));
1376
+ }
1377
+ return sanitized2;
1378
+ }
1379
+ if (!isPlainObject(value)) {
1380
+ return value;
1381
+ }
1382
+ const sanitized = options.createObject?.() ?? {};
1383
+ for (const [key, entry] of Object.entries(value)) {
1384
+ if (DANGEROUS_KEYS.has(key)) {
1385
+ continue;
1386
+ }
1387
+ sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
1388
+ }
1389
+ return sanitized;
1390
+ }
1391
+ function isPlainObject(value) {
1392
+ return Object.prototype.toString.call(value) === "[object Object]";
1393
+ }
1394
+
1357
1395
  // ../../src/internal/CacheStackSnapshotManager.ts
1358
1396
  var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
1359
1397
  var CacheStackSnapshotManager = class {
@@ -1378,7 +1416,16 @@ var CacheStackSnapshotManager = class {
1378
1416
  const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
1379
1417
  await Promise.all(
1380
1418
  batch.map(async (entry) => {
1381
- await Promise.all(this.options.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1419
+ await Promise.all(
1420
+ this.options.layers.map(async (layer) => {
1421
+ if (this.options.shouldSkipLayer(layer)) return;
1422
+ try {
1423
+ await layer.set(entry.key, entry.value, entry.ttl);
1424
+ } catch (error) {
1425
+ await this.options.handleLayerFailure(layer, "write", error);
1426
+ }
1427
+ })
1428
+ );
1382
1429
  await this.options.tagIndex.touch(entry.key);
1383
1430
  })
1384
1431
  );
@@ -1388,7 +1435,7 @@ var CacheStackSnapshotManager = class {
1388
1435
  const targetPath = await validateSnapshotFilePath(filePath, "write", snapshotBaseDir);
1389
1436
  const tempPath = path.join(
1390
1437
  path.dirname(targetPath),
1391
- `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
1438
+ `.layercache-${process.pid}-${Date.now()}-${randomBytes(8).toString("hex")}.tmp`
1392
1439
  );
1393
1440
  let handle;
1394
1441
  try {
@@ -1488,7 +1535,13 @@ var CacheStackSnapshotManager = class {
1488
1535
  });
1489
1536
  }
1490
1537
  sanitizeSnapshotValue(value) {
1491
- return this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1538
+ const roundTripped = this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1539
+ return sanitizeStructuredData(roundTripped, {
1540
+ label: "Snapshot value",
1541
+ maxDepth: 64,
1542
+ maxNodes: 1e4,
1543
+ createObject: () => /* @__PURE__ */ Object.create(null)
1544
+ });
1492
1545
  }
1493
1546
  };
1494
1547
 
@@ -1868,7 +1921,13 @@ var FetchRateLimiter = class {
1868
1921
  this.pendingBuckets.add(next.bucketKey);
1869
1922
  }
1870
1923
  this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
1871
- this.drain();
1924
+ if (!this.drainTimer) {
1925
+ this.drainTimer = setTimeout(() => {
1926
+ this.drainTimer = void 0;
1927
+ this.drain();
1928
+ }, 0);
1929
+ this.drainTimer.unref?.();
1930
+ }
1872
1931
  });
1873
1932
  }
1874
1933
  }
@@ -1910,6 +1969,9 @@ var FetchRateLimiter = class {
1910
1969
  }
1911
1970
  if (this.buckets.size >= MAX_BUCKETS) {
1912
1971
  this.evictIdleBuckets();
1972
+ if (this.buckets.size >= MAX_BUCKETS) {
1973
+ throw new Error(`FetchRateLimiter bucket limit (${MAX_BUCKETS}) exceeded.`);
1974
+ }
1913
1975
  }
1914
1976
  const bucket = { active: 0, startedAt: [] };
1915
1977
  this.buckets.set(bucketKey, bucket);
@@ -2388,38 +2450,6 @@ var TagIndex = class {
2388
2450
  }
2389
2451
  };
2390
2452
 
2391
- // ../../src/internal/StructuredDataSanitizer.ts
2392
- var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2393
- function sanitizeStructuredData(value, options) {
2394
- return sanitizeValue(value, 0, { count: 0 }, options);
2395
- }
2396
- function sanitizeValue(value, depth, state, options) {
2397
- state.count += 1;
2398
- if (state.count > options.maxNodes) {
2399
- throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
2400
- }
2401
- if (depth > options.maxDepth) {
2402
- throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
2403
- }
2404
- if (Array.isArray(value)) {
2405
- return value.map((entry) => sanitizeValue(entry, depth + 1, state, options));
2406
- }
2407
- if (!isPlainObject(value)) {
2408
- return value;
2409
- }
2410
- const sanitized = options.createObject?.() ?? {};
2411
- for (const [key, entry] of Object.entries(value)) {
2412
- if (DANGEROUS_KEYS.has(key)) {
2413
- continue;
2414
- }
2415
- sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
2416
- }
2417
- return sanitized;
2418
- }
2419
- function isPlainObject(value) {
2420
- return Object.prototype.toString.call(value) === "[object Object]";
2421
- }
2422
-
2423
2453
  // ../../src/serialization/JsonSerializer.ts
2424
2454
  var JsonSerializer = class {
2425
2455
  serialize(value) {
@@ -2437,27 +2467,34 @@ var JsonSerializer = class {
2437
2467
 
2438
2468
  // ../../src/stampede/StampedeGuard.ts
2439
2469
  var StampedeGuard = class {
2440
- mutexes = /* @__PURE__ */ new Map();
2470
+ inFlight = /* @__PURE__ */ new Map();
2441
2471
  async execute(key, task) {
2442
- const entry = this.getMutexEntry(key);
2472
+ const existing = this.inFlight.get(key);
2473
+ if (existing) {
2474
+ existing.references += 1;
2475
+ try {
2476
+ return await existing.promise;
2477
+ } finally {
2478
+ this.releaseEntry(key, existing);
2479
+ }
2480
+ }
2481
+ const entry = {
2482
+ promise: Promise.resolve().then(task),
2483
+ references: 1
2484
+ };
2485
+ this.inFlight.set(key, entry);
2443
2486
  try {
2444
- return await entry.mutex.runExclusive(task);
2487
+ return await entry.promise;
2445
2488
  } finally {
2446
- entry.references -= 1;
2447
- const current = this.mutexes.get(key);
2448
- if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
2449
- this.mutexes.delete(key);
2450
- }
2489
+ this.releaseEntry(key, entry);
2451
2490
  }
2452
2491
  }
2453
- getMutexEntry(key) {
2454
- let entry = this.mutexes.get(key);
2455
- if (!entry) {
2456
- entry = { mutex: new Mutex(), references: 0 };
2457
- this.mutexes.set(key, entry);
2492
+ releaseEntry(key, entry) {
2493
+ entry.references -= 1;
2494
+ const current = this.inFlight.get(key);
2495
+ if (current === entry && entry.references === 0) {
2496
+ this.inFlight.delete(key);
2458
2497
  }
2459
- entry.references += 1;
2460
- return entry;
2461
2498
  }
2462
2499
  };
2463
2500
 
@@ -2583,6 +2620,8 @@ var CacheStack = class extends EventEmitter {
2583
2620
  tagIndex: this.tagIndex,
2584
2621
  snapshotSerializer: this.snapshotSerializer,
2585
2622
  readLayerEntry: this.readLayerEntry.bind(this),
2623
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
2624
+ handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
2586
2625
  qualifyKey: this.qualifyKey.bind(this),
2587
2626
  stripQualifiedKey: this.stripQualifiedKey.bind(this),
2588
2627
  validateCacheKey,
@@ -2607,6 +2646,7 @@ var CacheStack = class extends EventEmitter {
2607
2646
  layerWriter;
2608
2647
  snapshots;
2609
2648
  backgroundRefreshes = /* @__PURE__ */ new Map();
2649
+ backgroundRefreshAbort = /* @__PURE__ */ new Map();
2610
2650
  layerDegradedUntil = /* @__PURE__ */ new Map();
2611
2651
  maintenance = new CacheStackMaintenance();
2612
2652
  ttlResolver;
@@ -2669,7 +2709,7 @@ var CacheStack = class extends EventEmitter {
2669
2709
  if (!fetcher) {
2670
2710
  return null;
2671
2711
  }
2672
- return this.fetchWithGuards(normalizedKey, fetcher, options);
2712
+ return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
2673
2713
  }
2674
2714
  /**
2675
2715
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
@@ -2851,7 +2891,7 @@ var CacheStack = class extends EventEmitter {
2851
2891
  }
2852
2892
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
2853
2893
  const layer = this.layers[layerIndex];
2854
- if (!layer) continue;
2894
+ if (!layer || this.shouldSkipLayer(layer)) continue;
2855
2895
  const keys = [...pending];
2856
2896
  if (keys.length === 0) {
2857
2897
  break;
@@ -2868,6 +2908,9 @@ var CacheStack = class extends EventEmitter {
2868
2908
  await layer.delete(key);
2869
2909
  continue;
2870
2910
  }
2911
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
2912
+ this.metricsCollector.increment("staleHits", indexesByKey.get(key)?.length ?? 1);
2913
+ }
2871
2914
  await this.tagIndex.touch(key);
2872
2915
  await this.backfill(key, stored, layerIndex - 1);
2873
2916
  resultsByKey.set(key, resolved.value);
@@ -3123,7 +3166,25 @@ var CacheStack = class extends EventEmitter {
3123
3166
  await this.unsubscribeInvalidation?.();
3124
3167
  await this.flushWriteBehindQueue();
3125
3168
  await this.maintenance.waitForGenerationCleanup();
3126
- await Promise.allSettled([...this.backgroundRefreshes.values()]);
3169
+ for (const key of this.backgroundRefreshAbort.keys()) {
3170
+ this.backgroundRefreshAbort.set(key, true);
3171
+ }
3172
+ await Promise.allSettled(
3173
+ [...this.backgroundRefreshes.values()].map((promise) => {
3174
+ let timer;
3175
+ return Promise.race([
3176
+ promise,
3177
+ new Promise((resolve) => {
3178
+ timer = setTimeout(resolve, 5e3);
3179
+ timer.unref?.();
3180
+ })
3181
+ ]).finally(() => {
3182
+ if (timer) clearTimeout(timer);
3183
+ });
3184
+ })
3185
+ );
3186
+ this.backgroundRefreshes.clear();
3187
+ this.backgroundRefreshAbort.clear();
3127
3188
  this.maintenance.disposeWriteBehindTimer();
3128
3189
  this.fetchRateLimiter.dispose();
3129
3190
  await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()));
@@ -3139,12 +3200,15 @@ var CacheStack = class extends EventEmitter {
3139
3200
  await this.handleInvalidationMessage(message);
3140
3201
  });
3141
3202
  }
3142
- async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
3203
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
3143
3204
  const fetchTask = async () => {
3144
- const secondHit = await this.readFromLayers(key, options, "fresh-only");
3145
- if (secondHit.found) {
3146
- this.metricsCollector.increment("hits");
3147
- return secondHit.value;
3205
+ const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
3206
+ if (shouldRecheckFreshLayers) {
3207
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
3208
+ if (secondHit.found) {
3209
+ this.metricsCollector.increment("hits");
3210
+ return secondHit.value;
3211
+ }
3148
3212
  }
3149
3213
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
3150
3214
  };
@@ -3152,12 +3216,22 @@ var CacheStack = class extends EventEmitter {
3152
3216
  if (!this.options.singleFlightCoordinator) {
3153
3217
  return fetchTask();
3154
3218
  }
3155
- return this.options.singleFlightCoordinator.execute(
3156
- key,
3157
- this.resolveSingleFlightOptions(),
3158
- fetchTask,
3159
- () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3160
- );
3219
+ try {
3220
+ return await this.options.singleFlightCoordinator.execute(
3221
+ key,
3222
+ this.resolveSingleFlightOptions(),
3223
+ fetchTask,
3224
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3225
+ );
3226
+ } catch (error) {
3227
+ if (!this.isGracefulDegradationEnabled()) {
3228
+ throw error;
3229
+ }
3230
+ this.metricsCollector.increment("degradedOperations");
3231
+ this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
3232
+ this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
3233
+ return fetchTask();
3234
+ }
3161
3235
  };
3162
3236
  if (this.options.stampedePrevention === false) {
3163
3237
  return singleFlightTask();
@@ -3390,15 +3464,19 @@ var CacheStack = class extends EventEmitter {
3390
3464
  }
3391
3465
  const clearEpoch = this.maintenance.currentClearEpoch();
3392
3466
  const keyEpoch = this.maintenance.currentKeyEpoch(key);
3467
+ this.backgroundRefreshAbort.set(key, false);
3393
3468
  const refresh = (async () => {
3394
3469
  this.metricsCollector.increment("refreshes");
3395
3470
  try {
3471
+ if (this.backgroundRefreshAbort.get(key)) return;
3396
3472
  await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
3397
3473
  } catch (error) {
3474
+ if (this.backgroundRefreshAbort.get(key)) return;
3398
3475
  this.metricsCollector.increment("refreshErrors");
3399
3476
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
3400
3477
  } finally {
3401
3478
  this.backgroundRefreshes.delete(key);
3479
+ this.backgroundRefreshAbort.delete(key);
3402
3480
  }
3403
3481
  })();
3404
3482
  this.backgroundRefreshes.set(key, refresh);
@@ -3501,7 +3579,7 @@ var CacheStack = class extends EventEmitter {
3501
3579
  timer.unref?.();
3502
3580
  })
3503
3581
  ]);
3504
- if (result && typeof result === "object" && "kind" in result) {
3582
+ if (result !== null && result !== void 0 && typeof result === "object" && "kind" in result) {
3505
3583
  if (result.kind === "error") {
3506
3584
  throw result.error;
3507
3585
  }
@@ -3519,7 +3597,7 @@ var CacheStack = class extends EventEmitter {
3519
3597
  }
3520
3598
  async observeOperation(name, attributes, execute) {
3521
3599
  const id = this.nextOperationId;
3522
- this.nextOperationId += 1;
3600
+ this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER;
3523
3601
  this.emit("operation-start", { id, name, attributes });
3524
3602
  try {
3525
3603
  const result = await execute();