• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

flyingsquirrel0419 / layercache / 29436437414

15 Jul 2026 05:25PM UTC coverage: 95.8% (-0.08%) from 95.882%
29436437414

Pull #101

github

web-flow
Merge cf2ff24f6 into e5107fec2
Pull Request #101: Fix cache security hardening

2009 of 2163 branches covered (92.88%)

Branch coverage included in aggregate %.

401 of 420 new or added lines in 25 files covered. (95.48%)

2 existing lines in 1 file now uncovered.

3534 of 3623 relevant lines covered (97.54%)

391.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.98
/src/CacheStack.ts
1
import { EventEmitter } from 'node:events'
2
import { CacheNamespace, validateNamespaceKey } from './CacheNamespace'
3
import { CacheKeyDiscovery } from './internal/CacheKeyDiscovery'
4
import {
5
  createInstanceId,
6
  normalizeForSerialization,
7
  serializeKeyPart,
8
  serializeOptions
9
} from './internal/CacheKeySerialization'
10
import {
11
  generationPrefix,
12
  qualifyGenerationKey,
13
  qualifyGenerationPattern,
14
  resolveGenerationCleanupBatchSize,
15
  resolveGenerationCleanupMaxMatches,
16
  resolveGenerationCleanupTarget,
17
  stripGenerationPrefix
18
} from './internal/CacheStackGeneration'
19
import { CacheStackInvalidationSupport } from './internal/CacheStackInvalidationSupport'
20
import { CacheStackLayerWriter, type CacheWriteFence, type CacheWriteKind } from './internal/CacheStackLayerWriter'
21
import { CacheStackMaintenance } from './internal/CacheStackMaintenance'
22
import { CacheStackReader } from './internal/CacheStackReader'
23
import {
24
  resolveRecoverableLayerFailure,
25
  shouldSkipLayer as shouldSkipDegradedLayer
26
} from './internal/CacheStackRuntimePolicy'
27
import { CacheStackSnapshotManager } from './internal/CacheStackSnapshotManager'
28
import {
29
  validateAdaptiveTtlOptions,
30
  validateCacheKey,
31
  validateCircuitBreakerOptions,
32
  validateContextEntryOptions,
33
  validateLayerNumberOption,
34
  validateNonNegativeNumber,
35
  validatePattern,
36
  validatePositiveNumber,
37
  validateRateLimitOptions,
38
  validateTag,
39
  validateTags,
40
  validateTtlPolicy
41
} from './internal/CacheStackValidation'
42
import { CircuitBreakerManager } from './internal/CircuitBreakerManager'
43
import { FetchRateLimiter } from './internal/FetchRateLimiter'
44
import { MetricsCollector } from './internal/MetricsCollector'
45
import { resolveStoredValue } from './internal/StoredValue'
46
import { TtlResolver } from './internal/TtlResolver'
47
import { TagIndex } from './invalidation/TagIndex'
48
import { JsonSerializer } from './serialization/JsonSerializer'
49
import { StampedeGuard } from './stampede/StampedeGuard'
50
import {
51
  type CacheAdaptiveTtlOptions,
52
  type CacheCircuitBreakerOptions,
53
  type CacheContextOptionsContext,
54
  type CacheEntryResult,
55
  type CacheEntryWriteKind,
56
  type CacheEntryWriteOptions,
57
  type CacheFetcher,
58
  type CacheFetcherContext,
59
  type CacheGetOptions,
60
  type CacheHealthCheckResult,
61
  type CacheHitRateSnapshot,
62
  type CacheInspectResult,
63
  type CacheLayer,
64
  type CacheLayerSetManyEntry,
65
  type CacheLogger,
66
  type CacheMGetEntry,
67
  type CacheMSetEntry,
68
  type CacheMetricsSnapshot,
69
  CacheMissError,
70
  type CacheSnapshotEntry,
71
  type CacheStackEvents,
72
  type CacheStackOptions,
73
  type CacheStatsSnapshot,
74
  type CacheTagIndex,
75
  type CacheTtlPolicy,
76
  type CacheWarmEntry,
77
  type CacheWarmOptions,
78
  type CacheWarmProgress,
79
  type CacheWrapOptions,
80
  type CacheWriteBehindOptions,
81
  type CacheWriteOptions,
82
  type InvalidationMessage,
83
  type LayerTtlMap
84
} from './types'
85

86
const DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1_024 * 1_024
14✔
87
const DEFAULT_SNAPSHOT_MAX_ENTRIES = 10_000
14✔
88
const DEFAULT_INVALIDATION_MAX_KEYS = 10_000
14✔
89
const DEFAULT_MAX_PROFILE_ENTRIES = 100_000
14✔
90

91
class DebugLogger implements CacheLogger {
92
  private readonly enabled: boolean
93

94
  constructor(enabled: boolean) {
95
    this.enabled = enabled
264✔
96
  }
97

98
  debug(message: string, context?: Record<string, unknown>): void {
99
    this.write('debug', message, context)
774✔
100
  }
101

102
  info(message: string, context?: Record<string, unknown>): void {
103
    this.write('info', message, context)
3✔
104
  }
105

106
  warn(message: string, context?: Record<string, unknown>): void {
107
    this.write('warn', message, context)
52✔
108
  }
109

110
  error(message: string, context?: Record<string, unknown>): void {
111
    this.write('error', message, context)
25✔
112
  }
113

114
  private write(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
115
    if (!this.enabled) {
854✔
116
      return
852✔
117
    }
118

119
    const suffix = context ? ` ${JSON.stringify(context)}` : ''
2✔
120
    console[level](`[layercache] ${message}${suffix}`)
854✔
121
  }
122
}
123

124
/** Typed overloads for EventEmitter so callers get autocomplete on event names. */
125
export interface CacheStack {
126
  /** Register a typed CacheStack event listener. */
127
  on<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
128
  /** Register a typed CacheStack event listener that runs once. */
129
  once<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
130
  /** Remove a typed CacheStack event listener. */
131
  off<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
132
  /** Remove all listeners, optionally only for one typed CacheStack event. */
133
  removeAllListeners<K extends keyof CacheStackEvents>(event?: K): this
134
  /** Return listeners registered for a typed CacheStack event. */
135
  listeners<K extends keyof CacheStackEvents>(event: K): Array<(data: CacheStackEvents[K]) => void>
136
  /** Return the listener count for a typed CacheStack event. */
137
  listenerCount<K extends keyof CacheStackEvents>(event: K): number
138
  /** Emit a typed CacheStack event. Mostly useful for custom integrations. */
139
  emit<K extends keyof CacheStackEvents>(event: K, data: CacheStackEvents[K]): boolean
140
}
141

142
/**
143
 * Multi-layer read-through cache coordinator.
144
 *
145
 * Layers are checked from fastest to slowest, partial hits are backfilled into
146
 * faster layers, and misses can be resolved by read-through fetchers.
147
 */
148
export class CacheStack extends EventEmitter {
149
  private readonly stampedeGuard: StampedeGuard
150
  private readonly metricsCollector = new MetricsCollector()
280✔
151
  private readonly instanceId = createInstanceId()
280✔
152
  private readonly startup: Promise<void>
153
  private unsubscribeInvalidation?: () => Promise<void> | void
154
  private readonly logger: CacheLogger
155
  private readonly tagIndex: CacheTagIndex
156
  private readonly keyDiscovery: CacheKeyDiscovery
157
  private readonly fetchRateLimiter = new FetchRateLimiter()
280✔
158
  private readonly snapshotSerializer = new JsonSerializer()
280✔
159
  private readonly invalidation: CacheStackInvalidationSupport
160
  private readonly layerWriter: CacheStackLayerWriter
161
  private readonly snapshots: CacheStackSnapshotManager
162
  private readonly layerDegradedUntil = new Map<string, number>()
280✔
163
  private readonly maintenance: CacheStackMaintenance
164
  private readonly ttlResolver: TtlResolver
165
  private readonly circuitBreakerManager: CircuitBreakerManager
166
  private nextOperationId = 0
280✔
167
  private currentGeneration?: number
168
  private isDisconnecting = false
280✔
169
  private readonly reader: CacheStackReader
170
  private disconnectPromise?: Promise<void>
171

172
  /**
173
   * Creates a cache stack from ordered layers and optional global behavior settings.
174
   */
175
  constructor(
176
    private readonly layers: CacheLayer[],
280✔
177
    private readonly options: CacheStackOptions = {}
280✔
178
  ) {
179
    super()
280✔
180

181
    if (layers.length === 0) {
280✔
182
      throw new Error('CacheStack requires at least one cache layer.')
1✔
183
    }
184

185
    this.validateConfiguration()
279✔
186

187
    const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES
279✔
188
    this.maintenance = new CacheStackMaintenance(options.writeCoordination)
280✔
189
    this.ttlResolver = new TtlResolver({ maxProfileEntries })
280✔
190
    this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries })
280✔
191
    this.stampedeGuard = new StampedeGuard({
280✔
192
      maxInFlight: options.stampedeMaxInFlight,
193
      entryTimeoutMs: options.stampedeEntryTimeoutMs,
194
      onEntryTimeout: (key) => this.maintenance.bumpKeyEpochs([key])
1✔
195
    })
196
    this.currentGeneration = options.generation
280✔
197

198
    if (options.publishSetInvalidation !== undefined) {
280✔
199
      console.warn(
1✔
200
        '[layercache] CacheStackOptions.publishSetInvalidation is deprecated. ' + 'Use broadcastL1Invalidation instead.'
201
      )
202
    }
203

204
    const debugEnv = process.env.DEBUG?.split(',').includes('layercache:debug') ?? false
271✔
205
    this.logger =
280✔
206
      typeof options.logger === 'object' ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv)
797✔
207
    this.tagIndex = options.tagIndex ?? new TagIndex()
280✔
208
    this.keyDiscovery = new CacheKeyDiscovery({
280✔
209
      layers: this.layers,
210
      tagIndex: this.tagIndex,
211
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
22✔
212
      handleLayerFailure: async (layer, operation, error) => {
213
        await this.handleLayerFailure(layer, operation, error)
1✔
214
      }
215
    })
216
    this.invalidation = new CacheStackInvalidationSupport({
280✔
217
      tagIndex: this.tagIndex,
218
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
64✔
219
      handleLayerFailure: async (layer, operation, error) => {
220
        await this.handleLayerFailure(layer, operation, error)
3✔
221
      }
222
    })
223
    this.layerWriter = new CacheStackLayerWriter({
280✔
224
      layers: this.layers,
225
      maintenance: this.maintenance,
226
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
442✔
227
      shouldWriteBehind: (layer) => this.shouldWriteBehind(layer),
425✔
228
      handleLayerFailure: async (layer, operation, error) => {
229
        await this.handleLayerFailure(layer, operation, error)
4✔
230
      },
231
      enqueueWriteBehind: this.enqueueWriteBehind.bind(this),
232
      resolveFreshTtl: this.resolveFreshTtl.bind(this),
233
      resolveLayerMs: this.resolveLayerMs.bind(this),
234
      globalStaleWhileRevalidate: this.options.staleWhileRevalidate,
235
      globalStaleIfError: this.options.staleIfError,
236
      writePolicy: this.options.writePolicy,
237
      onWriteFailures: (context, failures) => {
238
        this.metricsCollector.increment('writeFailures', failures.length)
3✔
239
        this.logger.debug?.('write-failure', {
3✔
240
          ...context,
241
          failures: failures.map((failure) => this.formatError(failure))
3✔
242
        })
243
      }
244
    })
245
    if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
302✔
246
      this.logger.warn?.(
21✔
247
        'Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation.'
248
      )
249
    }
250
    if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
303✔
251
      this.logger.warn?.(
4✔
252
        'Using the default in-memory TagIndex with a shared cache layer that does not implement keys() can leave invalidateByPattern() and invalidateByPrefix() incomplete after restarts. Use RedisTagIndex or implement keys() on the shared layer.'
253
      )
254
    }
255
    if (
271✔
256
      options.invalidationBus &&
304✔
257
      options.broadcastL1Invalidation === undefined &&
258
      options.publishSetInvalidation === undefined
259
    ) {
260
      this.logger.warn?.(
14✔
261
        'broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired.'
262
      )
263
    }
264
    this.snapshots = new CacheStackSnapshotManager({
271✔
265
      layers: this.layers,
266
      tagIndex: this.tagIndex,
267
      snapshotSerializer: this.snapshotSerializer,
268
      readLayerEntry: (layer, key) => this.reader.readLayerEntry(layer, key),
4✔
269
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
3✔
270
      handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
×
271
      qualifyKey: this.qualifyKey.bind(this),
272
      stripQualifiedKey: this.stripQualifiedKey.bind(this),
273
      validateCacheKey,
274
      formatError: this.formatError.bind(this)
275
    })
276
    this.reader = new CacheStackReader({
271✔
277
      layers: this.layers,
278
      metricsCollector: this.metricsCollector,
279
      maintenance: this.maintenance,
280
      tagIndex: this.tagIndex,
281
      circuitBreakerManager: this.circuitBreakerManager,
282
      fetchRateLimiter: this.fetchRateLimiter,
283
      stampedeGuard: this.stampedeGuard,
284
      ttlResolver: this.ttlResolver,
285
      logger: this.logger,
286
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
503✔
287
      handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
5✔
288
      emit: (event, data) => this.emit(event, data as never),
445✔
289
      emitError: (operation, context) => this.emitError(operation, context),
1✔
290
      formatError: (error) => this.formatError(error),
9✔
291
      storeEntry: (key, kind, value, options, fence) => this.storeEntry(key, kind, value, options, fence),
64✔
292
      recordCircuitFailure: (key, breakerKey, options, error) =>
293
        this.recordCircuitFailure(key, breakerKey, options, error),
12✔
294
      resolveLayerMs: (layerName, override, globalDefault, fallback) =>
295
        this.resolveLayerMs(layerName, override, globalDefault, fallback),
81✔
296
      sleep: (ms) => this.sleep(ms),
4✔
297
      withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
11✔
298
      isDisconnecting: () => this.isDisconnecting,
13✔
299
      isGracefulDegradationEnabled: () => this.isGracefulDegradationEnabled(),
2✔
300
      scheduleBackgroundRefreshDispatch: <T>(
301
        key: string,
302
        fetcher: CacheFetcher<T>,
303
        options?: CacheGetOptions,
304
        fetcherContext?: CacheFetcherContext<T>
305
      ) => this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext),
1✔
306
      stampedePrevention: options.stampedePrevention,
307
      singleFlightCoordinator: options.singleFlightCoordinator,
308
      singleFlightLeaseMs: options.singleFlightLeaseMs,
309
      singleFlightTimeoutMs: options.singleFlightTimeoutMs,
310
      singleFlightPollMs: options.singleFlightPollMs,
311
      singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
312
      backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
313
      negativeCaching: options.negativeCaching,
314
      cacheNullValues: options.cacheNullValues,
315
      refreshAhead: options.refreshAhead,
316
      circuitBreaker: options.circuitBreaker,
317
      fetcherRateLimit: options.fetcherRateLimit
318
    })
319
    this.initializeWriteBehind(options.writeBehind)
271✔
320
    this.startup = this.initialize()
271✔
321
  }
322

323
  /**
324
   * Read-through cache get.
325
   * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
326
   * and stores the result across all layers. Returns `null` if the key is not found
327
   * and no `fetcher` is provided.
328
   */
329
  async get<T>(key: string, fetcher?: CacheFetcher<T>, options?: CacheGetOptions): Promise<T | null> {
330
    return this.observeOperation('layercache.get', { 'layercache.key': String(key ?? '') }, async () => {
326✔
331
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
326✔
332
      this.validateWriteOptions(options)
326✔
333
      await this.awaitStartup('get')
326✔
334
      return this.reader.getPrepared(normalizedKey, fetcher, options)
320✔
335
    })
336
  }
337

338
  /**
339
   * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
340
   * Fetches and caches the value if not already present.
341
   */
342
  async getOrSet<T>(key: string, fetcher: CacheFetcher<T>, options?: CacheGetOptions): Promise<T | null> {
343
    return this.get(key, fetcher, options)
4✔
344
  }
345

346
  /**
347
   * Returns a discriminated cache entry, or `null` on miss.
348
   * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
349
   */
350
  async getEntry<T>(key: string): Promise<CacheEntryResult<T> | null> {
351
    return this.observeOperation('layercache.get_entry', { 'layercache.key': String(key ?? '') }, async () => {
6!
352
      const userKey = validateCacheKey(key)
6✔
353
      const normalizedKey = this.qualifyKey(userKey)
6✔
354
      await this.awaitStartup('getEntry')
6✔
355
      const readFence = {
6✔
356
        clearEpoch: this.maintenance.currentClearEpoch(),
357
        keyEpoch: this.maintenance.currentKeyEpoch(normalizedKey)
358
      }
359
      let sawRetainableValue = false
6✔
360

361
      for (let index = 0; index < this.layers.length; index += 1) {
6✔
362
        const layer = this.layers[index]
7✔
363
        if (!layer || this.shouldSkipLayer(layer)) {
7!
364
          continue
×
365
        }
366

367
        const readStart = performance.now()
7✔
368
        const stored = await this.readLayerEntry(layer, normalizedKey)
7✔
369
        this.metricsCollector.recordLatency(layer.name, performance.now() - readStart)
7✔
370
        if (stored === null) {
7✔
371
          this.metricsCollector.incrementLayer('missesByLayer', layer.name)
3✔
372
          continue
3✔
373
        }
374

375
        const resolved = resolveStoredValue<T>(stored)
4✔
376
        if (resolved.state === 'expired') {
4!
377
          await layer.delete(normalizedKey)
×
378
          continue
×
379
        }
380

381
        sawRetainableValue = true
4✔
382
        await this.tagIndex.touch(normalizedKey)
4✔
383
        await this.reader.backfill(normalizedKey, stored, index - 1, undefined, readFence)
4✔
384
        this.metricsCollector.increment('hits')
4✔
385
        if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
4!
386
          this.metricsCollector.increment('staleHits')
×
387
        }
388
        this.metricsCollector.incrementLayer('hitsByLayer', layer.name)
4✔
389
        this.logger.debug?.('hit', { key: normalizedKey, layer: layer.name, state: resolved.state })
4✔
390
        this.emit('hit', {
7✔
391
          key: normalizedKey,
392
          layer: layer.name,
393
          state: resolved.state as CacheStackEvents['hit']['state']
394
        })
395

396
        return {
7✔
397
          key: userKey,
398
          value: resolved.value,
399
          kind: resolved.envelope?.kind ?? 'value',
8✔
400
          state: resolved.state,
401
          layer: layer.name
402
        }
403
      }
404

405
      if (!sawRetainableValue) {
2!
406
        await this.tagIndex.remove(normalizedKey)
2✔
407
      }
408
      this.metricsCollector.increment('misses')
2✔
409
      this.logger.debug?.('miss', { key: normalizedKey, mode: 'getEntry' })
2✔
410
      this.emit('miss', { key: normalizedKey, mode: 'getEntry' })
6✔
411
      return null
6✔
412
    })
413
  }
414

415
  /**
416
   * Like `get()`, but throws `CacheMissError` instead of returning `null`.
417
   * Useful when the value is expected to exist or the fetcher is expected to
418
   * return non-null.
419
   */
420
  async getOrThrow<T>(key: string, fetcher?: CacheFetcher<T>, options?: CacheGetOptions): Promise<T> {
421
    const value = await this.get(key, fetcher, options)
4✔
422
    if (value === null) {
4✔
423
      throw new CacheMissError(key)
3✔
424
    }
425
    return value
1✔
426
  }
427

428
  /**
429
   * Returns true if the given key exists and is not expired in any layer.
430
   */
431
  async has(key: string): Promise<boolean> {
432
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
9✔
433
    await this.awaitStartup('has')
9✔
434

435
    for (const layer of this.layers) {
9✔
436
      if (this.shouldSkipLayer(layer)) {
17!
437
        continue
×
438
      }
439
      if (layer.has) {
17✔
440
        try {
5✔
441
          const exists = await layer.has(normalizedKey)
5✔
442
          if (exists) {
4✔
443
            return true
2✔
444
          }
445
        } catch {
446
          await this.reportRecoverableLayerFailure(layer, 'has', new Error(`has() failed for layer "${layer.name}"`))
1✔
447
          // fall through to next layer
448
        }
449
      } else {
450
        try {
12✔
451
          const value = await layer.get(normalizedKey)
12✔
452
          if (value !== null) {
8✔
453
            return true
2✔
454
          }
455
        } catch (error) {
456
          await this.reportRecoverableLayerFailure(layer, 'has', error)
4✔
457
          // fall through
458
        }
459
      }
460
    }
461
    return false
5✔
462
  }
463

464
  /**
465
   * Returns the remaining TTL in milliseconds for the key in the fastest layer
466
   * that has it, or null if the key is not found / has no TTL.
467
   */
468
  async ttl(key: string): Promise<number | null> {
469
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
4✔
470
    await this.awaitStartup('ttl')
4✔
471

472
    for (const layer of this.layers) {
4✔
473
      if (this.shouldSkipLayer(layer)) {
8✔
474
        continue
1✔
475
      }
476
      if (layer.ttl) {
7✔
477
        try {
6✔
478
          const remaining = await layer.ttl(normalizedKey)
6✔
479
          if (remaining !== null) {
4✔
480
            return remaining
3✔
481
          }
482
        } catch {
483
          // fall through
484
        }
485
      }
486
    }
487
    return null
1✔
488
  }
489

490
  /**
491
   * Stores a value in all cache layers. Overwrites any existing value.
492
   */
493
  async set<T>(key: string, value: T, options?: CacheWriteOptions): Promise<void> {
494
    await this.observeOperation('layercache.set', { 'layercache.key': String(key ?? '') }, async () => {
130✔
495
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
130✔
496
      this.validateWriteOptions(options)
130✔
497
      await this.awaitStartup('set')
130✔
498
      await this.storeEntry(normalizedKey, 'value', value, options)
125✔
499
    })
500
  }
501

502
  /**
503
   * Deletes the key from all layers and publishes an invalidation message.
504
   */
505
  async delete(key: string): Promise<void> {
506
    await this.observeOperation('layercache.delete', { 'layercache.key': String(key ?? '') }, async () => {
11✔
507
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
11✔
508
      await this.awaitStartup('delete')
11✔
509
      await this.deleteKeys([normalizedKey])
10✔
510
      await this.publishInvalidation({
10✔
511
        scope: 'key',
512
        keys: [normalizedKey],
513
        sourceId: this.instanceId,
514
        operation: 'delete'
515
      })
516
    })
517
  }
518

519
  /**
520
   * Clears every configured layer, removes tag metadata, resets internal TTL
521
   * profiles, and broadcasts a clear invalidation message.
522
   */
523
  async clear(): Promise<void> {
524
    await this.awaitStartup('clear')
4✔
525
    this.maintenance.beginClearEpoch()
4✔
526
    await Promise.all(this.layers.map((layer) => layer.clear()))
5✔
527
    await this.tagIndex.clear()
4✔
528
    this.ttlResolver.clearProfiles()
4✔
529
    this.circuitBreakerManager.clear()
4✔
530
    this.metricsCollector.increment('invalidations')
4✔
531
    this.logger.debug?.('clear')
4✔
532
    await this.publishInvalidation({ scope: 'clear', sourceId: this.instanceId, operation: 'clear' })
4✔
533
  }
534

535
  /**
536
   * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
537
   */
538
  async mdelete(keys: string[]): Promise<void> {
539
    if (keys.length === 0) {
5✔
540
      return
1✔
541
    }
542
    await this.awaitStartup('mdelete')
4✔
543
    const normalizedKeys = keys.map((k) => validateCacheKey(k))
6✔
544
    const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key))
6✔
545
    await this.deleteKeys(cacheKeys)
4✔
546
    await this.publishInvalidation({
4✔
547
      scope: 'keys',
548
      keys: cacheKeys,
549
      sourceId: this.instanceId,
550
      operation: 'delete'
551
    })
552
  }
553

554
  /**
555
   * Alias for `delete(key)` that matches the `invalidateBy*` API family.
556
   */
557
  async invalidateByKey(key: string): Promise<void> {
558
    await this.delete(key)
2✔
559
  }
560

561
  /**
562
   * Alias for `mdelete(keys)` that matches the `invalidateBy*` API family.
563
   */
564
  async invalidateByKeys(keys: string[]): Promise<void> {
565
    await this.mdelete(keys)
2✔
566
  }
567

568
  /**
569
   * Marks one exact key expired without deleting its stale value.
570
   */
571
  async expireByKey(key: string): Promise<void> {
572
    await this.observeOperation('layercache.expire_by_key', { 'layercache.key': String(key ?? '') }, async () => {
2!
573
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
2✔
574
      await this.awaitStartup('expireByKey')
2✔
575
      await this.expireKeys([normalizedKey])
2✔
576
      await this.publishInvalidation({
2✔
577
        scope: 'key',
578
        keys: [normalizedKey],
579
        sourceId: this.instanceId,
580
        operation: 'expire'
581
      })
582
    })
583
  }
584

585
  /**
586
   * Marks multiple exact keys expired without deleting their stale values.
587
   */
588
  async expireByKeys(keys: string[]): Promise<void> {
589
    await this.observeOperation('layercache.expire_by_keys', undefined, async () => {
3✔
590
      if (keys.length === 0) {
3✔
591
        return
1✔
592
      }
593

594
      const normalizedKeys = keys.map((k) => validateCacheKey(k))
3✔
595
      const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key))
3✔
596
      await this.awaitStartup('expireByKeys')
2✔
597
      await this.expireKeys(cacheKeys)
2✔
598
      await this.publishInvalidation({
2✔
599
        scope: 'keys',
600
        keys: cacheKeys,
601
        sourceId: this.instanceId,
602
        operation: 'expire'
603
      })
604
    })
605
  }
606

607
  /**
608
   * Reads many keys concurrently. Simple reads use layer-level bulk fast paths;
609
   * entries with fetchers or options fall back to per-entry read-through logic.
610
   */
611
  async mget<T>(entries: CacheMGetEntry<T>[]): Promise<Array<T | null>> {
612
    return this.observeOperation('layercache.mget', undefined, async () => {
9✔
613
      this.assertActive('mget')
9✔
614
      if (entries.length === 0) {
9✔
615
        return []
1✔
616
      }
617

618
      const normalizedEntries = entries.map((entry) => ({
18✔
619
        ...entry,
620
        key: this.qualifyKey(validateCacheKey(entry.key))
621
      }))
622
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
18✔
623
      const canFastPath = normalizedEntries.every((entry) => entry.fetch === undefined && entry.options === undefined)
16✔
624
      if (!canFastPath) {
8✔
625
        await this.awaitStartup('mget')
2✔
626
        const pendingReads = new Map<
2✔
627
          string,
628
          {
629
            promise: Promise<T | null>
630
            fetch?: CacheFetcher<T>
631
            optionsSignature: string
632
          }
633
        >()
634

635
        return Promise.all(
2✔
636
          normalizedEntries.map((entry) => {
637
            const optionsSignature = serializeOptions(entry.options)
4✔
638
            const existing = pendingReads.get(entry.key)
4✔
639
            if (!existing) {
4✔
640
              const promise = this.reader.getPrepared(entry.key, entry.fetch, entry.options)
2✔
641
              pendingReads.set(entry.key, {
2✔
642
                promise,
643
                fetch: entry.fetch,
644
                optionsSignature
645
              })
646
              return promise
2✔
647
            }
648

649
            if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2!
650
              const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key
2!
651
              throw new Error(`mget received conflicting entries for key "${displayKey}".`)
2✔
652
            }
653

654
            return existing.promise
×
655
          })
656
        )
657
      }
658

659
      await this.awaitStartup('mget')
6✔
660
      const pending = new Set<string>()
6✔
661
      const indexesByKey = new Map<string, number[]>()
6✔
662
      const resultsByKey = new Map<string, T | null>()
6✔
663
      const readFences = new Map(
6✔
664
        normalizedEntries.map(({ key }) => [
14✔
665
          key,
666
          {
667
            clearEpoch: this.maintenance.currentClearEpoch(),
668
            keyEpoch: this.maintenance.currentKeyEpoch(key)
669
          }
670
        ])
671
      )
672

673
      for (let index = 0; index < normalizedEntries.length; index += 1) {
6✔
674
        const entry = normalizedEntries[index]
14✔
675
        if (!entry) continue
14!
676
        const key = entry.key
14✔
677
        const indexes = indexesByKey.get(key) ?? []
14✔
678
        indexes.push(index)
14✔
679
        indexesByKey.set(key, indexes)
14✔
680
        pending.add(key)
14✔
681
      }
682

683
      for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
6✔
684
        const layer = this.layers[layerIndex]
6✔
685
        if (!layer || this.shouldSkipLayer(layer)) continue
6!
686
        const keys = [...pending]
6✔
687
        if (keys.length === 0) {
6!
688
          break
×
689
        }
690

691
        const values = layer.getMany
6!
692
          ? await layer.getMany(keys)
693
          : await Promise.all(keys.map((key) => this.reader.readLayerEntry(layer, key)))
×
694

695
        for (let offset = 0; offset < values.length; offset += 1) {
×
696
          const key = keys[offset]
13✔
697
          const stored = values[offset]
13✔
698
          if (!key || stored === null) {
13✔
699
            continue
2✔
700
          }
701

702
          const resolved = resolveStoredValue<T>(stored)
11✔
703
          if (resolved.state === 'expired') {
11✔
704
            await layer.delete(key)
1✔
705
            continue
1✔
706
          }
707

708
          if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
10!
709
            this.metricsCollector.increment('staleHits', indexesByKey.get(key)?.length ?? 1)
×
710
          }
711

712
          await this.tagIndex.touch(key)
10✔
713
          await this.reader.backfill(key, stored, layerIndex - 1, undefined, readFences.get(key))
10✔
714
          resultsByKey.set(key, resolved.value)
10✔
715
          pending.delete(key)
10✔
716
          this.metricsCollector.increment('hits', indexesByKey.get(key)?.length ?? 1)
10!
717
        }
718
      }
719

720
      if (pending.size > 0) {
6✔
721
        for (const key of pending) {
2✔
722
          await this.tagIndex.remove(key)
3✔
723
          this.metricsCollector.increment('misses', indexesByKey.get(key)?.length ?? 1)
3!
724
        }
725
      }
726

727
      return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null)
14✔
728
    })
729
  }
730

731
  /**
732
   * Writes many entries concurrently using each layer's bulk write fast path
733
   * when available.
734
   */
735
  async mset<T>(entries: CacheMSetEntry<T>[]): Promise<void> {
736
    await this.observeOperation('layercache.mset', undefined, async () => {
14✔
737
      this.assertActive('mset')
14✔
738
      const normalizedEntries = entries.map((entry) => ({
32✔
739
        ...entry,
740
        key: this.qualifyKey(validateCacheKey(entry.key))
741
      }))
742
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
32✔
743
      await this.awaitStartup('mset')
14✔
744
      await this.writeBatch(normalizedEntries)
14✔
745
    })
746
  }
747

748
  /**
749
   * Pre-populates cache entries by running their fetchers with bounded
750
   * concurrency. Higher-priority entries run first.
751
   */
752
  async warm(entries: CacheWarmEntry[], options: CacheWarmOptions = {}): Promise<void> {
4✔
753
    this.assertActive('warm')
4✔
754
    const concurrency = Math.max(1, options.concurrency ?? 4)
4✔
755
    const total = entries.length
4✔
756
    let completed = 0
4✔
757
    const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
4!
758
    const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
4!
759
      while (queue.length > 0) {
4✔
760
        const entry = queue.shift()
6✔
761
        if (!entry) {
6!
762
          return
×
763
        }
764

765
        let success = false
6✔
766
        try {
6✔
767
          await this.get(entry.key, entry.fetcher, entry.options)
6✔
768
          this.emit('warm', { key: entry.key })
4✔
769
          success = true
4✔
770
        } catch (error) {
771
          this.emitError('warm', { key: entry.key, error: this.formatError(error) })
2✔
772
          if (!options.continueOnError) {
2✔
773
            throw error
1✔
774
          }
775
        } finally {
776
          completed += 1
6✔
777
          const progress: CacheWarmProgress = { completed, total, key: entry.key, success }
6✔
778
          options.onProgress?.(progress)
6✔
779
        }
780
      }
781
    })
782

783
    await Promise.all(workers)
4✔
784
  }
785

786
  /**
787
   * Returns a cached version of `fetcher`. The cache key is derived from
788
   * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
789
   */
790
  wrap<TArgs extends unknown[], TResult>(
791
    prefix: string,
792
    fetcher: (...args: TArgs) => Promise<TResult>,
793
    options: CacheWrapOptions<TArgs> = {}
12✔
794
  ): (...args: TArgs) => Promise<TResult | null> {
795
    return (...args: TArgs) => {
12✔
796
      const suffix = options.keyResolver
20✔
797
        ? options.keyResolver(...args)
798
        : args.map((argument) => serializeKeyPart(argument)).join(':')
13✔
799
      const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix
20!
800
      return this.get<TResult>(key, () => fetcher(...args), options)
20✔
801
    }
802
  }
803

804
  /**
805
   * Creates a `CacheNamespace` that automatically prefixes all keys with
806
   * `prefix:`. Useful for multi-tenant or module-level isolation.
807
   */
808
  namespace(prefix: string): CacheNamespace {
809
    validateNamespaceKey(prefix)
49✔
810
    return new CacheNamespace(this, prefix)
49✔
811
  }
812

813
  /**
814
   * Deletes every key currently associated with `tag` and broadcasts an
815
   * invalidation message.
816
   */
817
  async invalidateByTag(tag: string): Promise<void> {
818
    await this.observeOperation('layercache.invalidate_by_tag', undefined, async () => {
8✔
819
      validateTag(tag)
8✔
820
      await this.awaitStartup('invalidateByTag')
8✔
821
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
7✔
822
      await this.deleteKeys(keys)
6✔
823
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
824
    })
825
  }
826

827
  /**
828
   * Marks every key associated with `tag` as expired while preserving stale
829
   * windows for stale serving.
830
   */
831
  async expireByTag(tag: string): Promise<void> {
832
    await this.observeOperation('layercache.expire_by_tag', undefined, async () => {
4✔
833
      validateTag(tag)
4✔
834
      await this.awaitStartup('expireByTag')
4✔
835
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
4✔
836
      await this.expireKeys(keys)
4✔
837
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
4✔
838
    })
839
  }
840

841
  /**
842
   * Deletes keys associated with any or all of the provided tags and broadcasts
843
   * an invalidation message.
844
   */
845
  async invalidateByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
5✔
846
    await this.observeOperation('layercache.invalidate_by_tags', undefined, async () => {
5✔
847
      if (tags.length === 0) {
5✔
848
        return
1✔
849
      }
850

851
      validateTags(tags)
4✔
852
      await this.awaitStartup('invalidateByTags')
4✔
853
      const keysByTag = await Promise.all(
4✔
854
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
7✔
855
      )
856
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
3✔
857
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
5✔
858

859
      await this.deleteKeys(keys)
5✔
860
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
861
    })
862
  }
863

864
  /**
865
   * Marks keys associated with any or all of the provided tags as expired while
866
   * preserving stale windows for stale serving.
867
   */
868
  async expireByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
3✔
869
    await this.observeOperation('layercache.expire_by_tags', undefined, async () => {
3✔
870
      if (tags.length === 0) {
3✔
871
        return
1✔
872
      }
873

874
      validateTags(tags)
2✔
875
      await this.awaitStartup('expireByTags')
2✔
876
      const keysByTag = await Promise.all(
2✔
877
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
4✔
878
      )
879
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
2!
880
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
3✔
881

882
      await this.expireKeys(keys)
3✔
883
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
884
    })
885
  }
886

887
  /**
888
   * Deletes keys matching a wildcard pattern such as `user:*`.
889
   */
890
  async invalidateByPattern(pattern: string): Promise<void> {
891
    await this.observeOperation('layercache.invalidate_by_pattern', undefined, async () => {
5✔
892
      validatePattern(pattern)
5✔
893
      await this.awaitStartup('invalidateByPattern')
5✔
894
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
5✔
895
        this.qualifyPattern(pattern),
896
        this.invalidationMaxKeys()
897
      )
898
      await this.deleteKeys(keys)
4✔
899
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
4✔
900
    })
901
  }
902

903
  /**
904
   * Marks keys matching a wildcard pattern as expired while preserving stale
905
   * windows for stale serving.
906
   */
907
  async expireByPattern(pattern: string): Promise<void> {
908
    await this.observeOperation('layercache.expire_by_pattern', undefined, async () => {
3✔
909
      validatePattern(pattern)
3✔
910
      await this.awaitStartup('expireByPattern')
3✔
911
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
3✔
912
        this.qualifyPattern(pattern),
913
        this.invalidationMaxKeys()
914
      )
915
      await this.expireKeys(keys)
3✔
916
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
3✔
917
    })
918
  }
919

920
  /**
921
   * Deletes keys that start with the provided prefix.
922
   */
923
  async invalidateByPrefix(prefix: string): Promise<void> {
924
    await this.observeOperation('layercache.invalidate_by_prefix', undefined, async () => {
8✔
925
      await this.awaitStartup('invalidateByPrefix')
8✔
926
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
8✔
927
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
8✔
928
      await this.deleteKeys(keys)
7✔
929
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
7✔
930
    })
931
  }
932

933
  /**
934
   * Marks keys that start with the provided prefix as expired while preserving
935
   * stale windows for stale serving.
936
   */
937
  async expireByPrefix(prefix: string): Promise<void> {
938
    await this.observeOperation('layercache.expire_by_prefix', undefined, async () => {
3✔
939
      await this.awaitStartup('expireByPrefix')
3✔
940
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
3✔
941
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
3✔
942
      await this.expireKeys(keys)
2✔
943
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
944
    })
945
  }
946

947
  /**
948
   * Returns cumulative cache metrics since startup or the last `resetMetrics()`.
949
   */
950
  getMetrics(): CacheMetricsSnapshot {
951
    return this.metricsCollector.snapshot
58✔
952
  }
953

954
  /**
955
   * Runs an operation while collecting only the metrics emitted by its async context.
956
   * Used by namespaces so metrics tracking does not serialize the operation itself.
957
   */
958
  async captureMetrics<T>(operation: () => Promise<T>): Promise<{ result: T; metrics: CacheMetricsSnapshot }> {
959
    return this.metricsCollector.capture(operation)
106✔
960
  }
961

962
  /**
963
   * Returns metrics plus layer degradation state and active background refresh count.
964
   */
965
  getStats(): CacheStatsSnapshot {
966
    return {
23✔
967
      metrics: this.getMetrics(),
968
      layers: this.layers.map((layer) => ({
27✔
969
        name: layer.name,
970
        isLocal: Boolean(layer.isLocal),
971
        degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
50✔
972
      })),
973
      backgroundRefreshes: this.reader.activeRefreshCount
974
    }
975
  }
976

977
  /**
978
   * Resets cumulative metrics counters.
979
   */
980
  resetMetrics(): void {
981
    this.metricsCollector.reset()
1✔
982
  }
983

984
  /**
985
   * Returns computed hit-rate statistics (overall and per-layer).
986
   */
987
  getHitRate(): CacheHitRateSnapshot {
988
    return this.metricsCollector.hitRate()
6✔
989
  }
990

991
  /**
992
   * Runs each layer's `ping()` hook when available and returns per-layer health
993
   * and latency information.
994
   */
995
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
996
    await this.startup
2✔
997

998
    return Promise.all(
2✔
999
      this.layers.map(async (layer) => {
1000
        const startedAt = performance.now()
4✔
1001
        try {
4✔
1002
          const healthy = layer.ping ? await layer.ping() : true
4✔
1003
          return {
4✔
1004
            layer: layer.name,
1005
            healthy,
1006
            latencyMs: performance.now() - startedAt
1007
          }
1008
        } catch (error) {
1009
          return {
1✔
1010
            layer: layer.name,
1011
            healthy: false,
1012
            latencyMs: performance.now() - startedAt,
1013
            error: this.formatError(error)
1014
          }
1015
        }
1016
      })
1017
    )
1018
  }
1019

1020
  /**
1021
   * Rotates the active generation prefix used for all future cache keys.
1022
   * Previous-generation keys remain in the underlying layers until they expire,
1023
   * unless `generationCleanup` is enabled to prune them in the background.
1024
   */
1025
  bumpGeneration(nextGeneration?: number): number {
1026
    const current = this.currentGeneration ?? 0
5!
1027
    const previousGeneration = this.currentGeneration
5✔
1028
    const updatedGeneration = nextGeneration ?? current + 1
5✔
1029
    const generationToCleanup = resolveGenerationCleanupTarget({
5✔
1030
      previousGeneration,
1031
      nextGeneration: updatedGeneration,
1032
      generationCleanup: this.options.generationCleanup
1033
    })
1034

1035
    this.currentGeneration = updatedGeneration
5✔
1036
    if (generationToCleanup !== null) {
5✔
1037
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
1038
    }
1039

1040
    return this.currentGeneration
5✔
1041
  }
1042

1043
  /**
1044
   * Returns the active generation prefix number used for future cache keys.
1045
   */
1046
  getGeneration(): number | undefined {
1047
    return this.currentGeneration
3✔
1048
  }
1049

1050
  /**
1051
   * Returns detailed metadata about a single cache key: which layers contain it,
1052
   * remaining fresh/stale/error TTLs, and associated tags.
1053
   * Returns `null` if the key does not exist in any layer.
1054
   */
1055
  async inspect(key: string): Promise<CacheInspectResult | null> {
1056
    const userKey = validateCacheKey(key)
30✔
1057
    const normalizedKey = this.qualifyKey(userKey)
30✔
1058
    await this.awaitStartup('inspect')
30✔
1059

1060
    const foundInLayers: string[] = []
30✔
1061
    let freshTtlMs: number | null = null
30✔
1062
    let staleTtlMs: number | null = null
30✔
1063
    let errorTtlMs: number | null = null
30✔
1064
    let isStale = false
30✔
1065

1066
    for (const layer of this.layers) {
30✔
1067
      if (this.shouldSkipLayer(layer)) {
31!
1068
        continue
×
1069
      }
1070
      const stored = await this.readLayerEntry(layer, normalizedKey)
31✔
1071
      if (stored === null) {
31✔
1072
        continue
1✔
1073
      }
1074

1075
      const resolved = resolveStoredValue(stored)
30✔
1076
      if (resolved.state === 'expired') {
30!
1077
        continue
×
1078
      }
1079

1080
      foundInLayers.push(layer.name)
30✔
1081

1082
      // Take TTL info from the first (fastest) layer that has it
1083
      if (foundInLayers.length === 1 && resolved.envelope) {
30✔
1084
        const now = Date.now()
29✔
1085
        freshTtlMs =
29✔
1086
          resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.freshUntil - now)) : null
29!
1087
        staleTtlMs =
29✔
1088
          resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.staleUntil - now)) : null
29✔
1089
        errorTtlMs =
29✔
1090
          resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.errorUntil - now)) : null
29✔
1091
        isStale = resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error'
29✔
1092
      }
1093
    }
1094

1095
    if (foundInLayers.length === 0) {
30✔
1096
      return null
1✔
1097
    }
1098

1099
    const tags = await this.getTagsForKey(normalizedKey)
29✔
1100

1101
    return { key: userKey, foundInLayers, freshTtlMs, staleTtlMs, errorTtlMs, isStale, tags }
29✔
1102
  }
1103

1104
  /**
1105
   * Exports cache entries from configured layers for process-local snapshots.
1106
   */
1107
  async exportState(): Promise<CacheSnapshotEntry[]> {
1108
    await this.awaitStartup('exportState')
2✔
1109
    return this.snapshots.exportState(this.snapshotMaxEntries())
2✔
1110
  }
1111

1112
  /**
1113
   * Imports entries produced by `exportState()` into the configured layers.
1114
   */
1115
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
1116
    await this.awaitStartup('importState')
1✔
1117
    await this.snapshots.importState(entries)
1✔
1118
  }
1119

1120
  /**
1121
   * Writes a snapshot file containing current cache entries.
1122
   */
1123
  async persistToFile(filePath: string): Promise<void> {
1124
    this.assertActive('persistToFile')
4✔
1125
    await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries())
4✔
1126
  }
1127

1128
  /**
1129
   * Restores cache entries from a snapshot file.
1130
   */
1131
  async restoreFromFile(filePath: string): Promise<void> {
1132
    this.assertActive('restoreFromFile')
8✔
1133
    await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes())
8✔
1134
  }
1135

1136
  /**
1137
   * Flushes background work, unsubscribes from buses, disposes timers, and then
1138
   * disposes each layer that provides `dispose()`.
1139
   */
1140
  async disconnect(): Promise<void> {
1141
    if (!this.disconnectPromise) {
28!
1142
      this.isDisconnecting = true
28✔
1143
      this.disconnectPromise = (async () => {
28✔
1144
        await this.startup
28✔
1145
        await this.unsubscribeInvalidation?.()
28✔
1146
        await this.flushWriteBehindQueue()
28✔
1147
        await this.maintenance.waitForGenerationCleanup()
28✔
1148
        this.reader.abortAllRefreshes()
28✔
1149
        await Promise.allSettled(
28✔
1150
          this.reader.getAllRefreshPromises().map((promise) => {
1151
            let timer: ReturnType<typeof setTimeout> | undefined
1152
            return Promise.race([
×
1153
              promise,
1154
              new Promise<void>((resolve) => {
1155
                timer = setTimeout(resolve, 5_000)
×
1156
                timer.unref?.()
×
1157
              })
1158
            ]).finally(() => {
1159
              if (timer) clearTimeout(timer)
×
1160
            })
1161
          })
1162
        )
1163
        this.maintenance.disposeWriteBehindTimer()
28✔
1164
        this.fetchRateLimiter.dispose()
28✔
1165
        await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()))
42✔
1166
      })()
1167
    }
1168

1169
    await this.disconnectPromise
28✔
1170
  }
1171

1172
  private async initialize(): Promise<void> {
1173
    if (!this.options.invalidationBus) {
271✔
1174
      return
253✔
1175
    }
1176

1177
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
18✔
1178
      await this.handleInvalidationMessage(message)
12✔
1179
    })
1180
  }
1181

1182
  private async storeEntry(
1183
    key: string,
1184
    kind: CacheWriteKind,
1185
    value: unknown,
1186
    options?: CacheWriteOptions,
1187
    fence?: CacheWriteFence
1188
  ): Promise<boolean> {
1189
    const resolvedOptions = this.resolveContextOptions(key, kind, value, options)
189✔
1190
    const clearEpoch = this.maintenance.currentClearEpoch()
189✔
1191
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
189✔
1192
    const committed = await this.layerWriter.writeAcrossLayers(key, kind, value, resolvedOptions, fence)
189✔
1193
    if (!committed) return false
184✔
1194
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
183!
NEW
1195
      return false
×
1196
    }
1197
    if (resolvedOptions?.tags) {
183✔
1198
      await this.tagIndex.track(key, resolvedOptions.tags)
29✔
1199
    } else {
1200
      await this.tagIndex.touch(key)
154✔
1201
    }
1202

1203
    this.metricsCollector.increment('sets')
183✔
1204
    this.logger.debug?.('set', { key, kind, tags: resolvedOptions?.tags })
183✔
1205
    this.emit('set', { key, kind: kind as string, tags: resolvedOptions?.tags })
189✔
1206
    if (this.shouldBroadcastL1Invalidation()) {
189✔
1207
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
1208
    }
1209
    return true
183✔
1210
  }
1211

1212
  private async writeBatch(
1213
    entries: Array<{ key: string; value: unknown; options?: CacheWriteOptions }>
1214
  ): Promise<void> {
1215
    const resolvedEntries = entries.map((entry) => ({
32✔
1216
      ...entry,
1217
      options: this.resolveContextOptions(entry.key, 'value', entry.value, entry.options)
1218
    }))
1219
    const { clearEpoch, entryEpochs } = await this.layerWriter.writeBatch(resolvedEntries)
14✔
1220
    if (clearEpoch !== this.maintenance.currentClearEpoch()) {
13!
1221
      return
×
1222
    }
1223

1224
    for (const entry of resolvedEntries) {
13✔
1225
      if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
30!
1226
        continue
×
1227
      }
1228
      if (entry.options?.tags) {
30✔
1229
        await this.tagIndex.track(entry.key, entry.options.tags)
2✔
1230
      } else {
1231
        await this.tagIndex.touch(entry.key)
28✔
1232
      }
1233

1234
      this.metricsCollector.increment('sets')
30✔
1235
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
30✔
1236
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
30✔
1237
    }
1238

1239
    if (this.shouldBroadcastL1Invalidation()) {
13✔
1240
      await this.publishInvalidation({
1✔
1241
        scope: 'keys',
1242
        keys: entries.map((entry) => entry.key),
2✔
1243
        sourceId: this.instanceId,
1244
        operation: 'write'
1245
      })
1246
    }
1247
  }
1248

1249
  private resolveFreshTtl(
1250
    key: string,
1251
    layerName: string,
1252
    kind: CacheWriteKind,
1253
    options: CacheWriteOptions | undefined,
1254
    fallbackTtl: number | undefined,
1255
    value: unknown
1256
  ): number | undefined {
1257
    return this.ttlResolver.resolveFreshTtl(
237✔
1258
      key,
1259
      layerName,
1260
      kind,
1261
      options,
1262
      fallbackTtl,
1263
      this.options.negativeTtl,
1264
      undefined,
1265
      value
1266
    )
1267
  }
1268

1269
  private resolveLayerMs(
1270
    layerName: string,
1271
    override: number | LayerTtlMap | undefined,
1272
    globalDefault?: number | LayerTtlMap,
1273
    fallback?: number
1274
  ): number | undefined {
1275
    return this.ttlResolver.resolveLayerMs(layerName, override, globalDefault, fallback)
555✔
1276
  }
1277

1278
  private resolveContextOptions(
1279
    key: string,
1280
    kind: CacheEntryWriteKind,
1281
    value: unknown,
1282
    options: CacheWriteOptions | undefined
1283
  ): CacheWriteOptions | undefined {
1284
    if (!options?.contextOptions) {
221✔
1285
      return options
213✔
1286
    }
1287

1288
    const { contextOptions, ...baseOptions } = options
8✔
1289
    let overrides: CacheEntryWriteOptions | undefined
1290
    try {
8✔
1291
      overrides = contextOptions({ key, value, kind } as CacheContextOptionsContext)
8✔
1292
    } catch (error) {
1293
      throw new Error(`options.contextOptions() failed for key "${key}": ${this.formatError(error)}`)
1✔
1294
    }
1295
    if (!overrides) {
7✔
1296
      return baseOptions
1✔
1297
    }
1298
    if (!this.isPlainObject(overrides)) {
6✔
1299
      throw new Error(
3✔
1300
        `options.contextOptions() must return a plain object or undefined for key "${key}". Async resolvers are not supported.`
1301
      )
1302
    }
1303

1304
    try {
3✔
1305
      validateContextEntryOptions('options.contextOptions()', overrides)
3✔
1306
    } catch (error) {
1307
      throw new Error(
1✔
1308
        `options.contextOptions() returned invalid entry options for key "${key}": ${this.formatError(error)}`
1309
      )
1310
    }
1311
    return {
2✔
1312
      ...baseOptions,
1313
      ...overrides
1314
    }
1315
  }
1316

1317
  private isPlainObject(value: unknown): value is Record<string, unknown> {
1318
    if (!value || typeof value !== 'object' || Array.isArray(value)) {
6✔
1319
      return false
2✔
1320
    }
1321

1322
    const prototype = Object.getPrototypeOf(value)
4✔
1323
    return prototype === Object.prototype || prototype === null
4✔
1324
  }
1325

1326
  private async deleteKeys(keys: string[]): Promise<void> {
1327
    if (keys.length === 0) {
37✔
1328
      return
4✔
1329
    }
1330

1331
    this.maintenance.bumpKeyEpochs(keys)
33✔
1332
    await this.invalidation.deleteKeysFromLayers(this.layers, keys)
33✔
1333

1334
    for (const key of keys) {
33✔
1335
      await this.tagIndex.remove(key)
42✔
1336
      this.ttlResolver.deleteProfile(key)
42✔
1337
      this.circuitBreakerManager.delete(`key:${key}`)
42✔
1338
    }
1339

1340
    this.metricsCollector.increment('deletes', keys.length)
33✔
1341
    this.metricsCollector.increment('invalidations')
33✔
1342
    this.logger.debug?.('delete', { keys })
33✔
1343
    this.emit('delete', { keys })
37✔
1344
  }
1345

1346
  private async expireKeys(keys: string[]): Promise<void> {
1347
    if (keys.length === 0) {
15✔
1348
      return
1✔
1349
    }
1350

1351
    this.maintenance.bumpKeyEpochs(keys)
14✔
1352
    const foundKeys = await this.expireKeysInLayers(keys, this.layers)
14✔
1353

1354
    for (const key of keys) {
14✔
1355
      if (foundKeys.has(key)) {
18✔
1356
        continue
17✔
1357
      }
1358

1359
      await this.tagIndex.remove(key)
1✔
1360
      this.ttlResolver.deleteProfile(key)
1✔
1361
      this.circuitBreakerManager.delete(`key:${key}`)
1✔
1362
    }
1363

1364
    this.metricsCollector.increment('invalidations')
14✔
1365
    this.logger.debug?.('expire', { keys })
14✔
1366
    this.emit('expire', { keys })
15✔
1367
  }
1368

1369
  private async expireKeysInLayers(keys: string[], layers: CacheLayer[]): Promise<Set<string>> {
1370
    if (keys.length === 0) {
16✔
1371
      return new Set()
1✔
1372
    }
1373

1374
    return this.invalidation.expireKeysInLayers(layers, keys)
15✔
1375
  }
1376

1377
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1378
    if (!this.options.invalidationBus) {
59✔
1379
      return
52✔
1380
    }
1381

1382
    await this.options.invalidationBus.publish(message)
7✔
1383
  }
1384

1385
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1386
    if (message.sourceId === this.instanceId) {
15✔
1387
      return
7✔
1388
    }
1389

1390
    const localLayers = this.layers.filter((layer) => layer.isLocal)
12✔
1391
    if (message.scope === 'clear') {
8✔
1392
      this.maintenance.beginClearEpoch()
2✔
1393
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1394
      await this.tagIndex.clear()
2✔
1395
      this.ttlResolver.clearProfiles()
2✔
1396
      this.circuitBreakerManager.clear()
2✔
1397
      return
2✔
1398
    }
1399

1400
    const keys = message.keys ?? []
6!
1401
    this.maintenance.bumpKeyEpochs(keys)
15✔
1402
    if (message.operation === 'expire') {
15✔
1403
      await this.expireKeysInLayers(keys, localLayers)
1✔
1404
      return
1✔
1405
    }
1406

1407
    await this.invalidation.deleteKeysFromLayers(localLayers, keys)
5✔
1408

1409
    if (message.operation !== 'write') {
5✔
1410
      for (const key of keys) {
2✔
1411
        await this.tagIndex.remove(key)
3✔
1412
        this.ttlResolver.deleteProfile(key)
3✔
1413
        this.circuitBreakerManager.delete(`key:${key}`)
3✔
1414
      }
1415
    }
1416
  }
1417

1418
  private async getTagsForKey(key: string): Promise<string[]> {
1419
    if (this.tagIndex.tagsForKey) {
31✔
1420
      return this.tagIndex.tagsForKey(key)
30✔
1421
    }
1422
    return []
1✔
1423
  }
1424

1425
  private formatError(error: unknown): string {
1426
    if (error instanceof Error) {
55✔
1427
      return error.message
54✔
1428
    }
1429

1430
    return String(error)
1✔
1431
  }
1432

1433
  private sleep(ms: number): Promise<void> {
1434
    return new Promise((resolve) => setTimeout(resolve, ms))
4✔
1435
  }
1436

1437
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1438
    if (timeoutMs <= 0) {
14✔
1439
      return promise
1✔
1440
    }
1441

1442
    let timer: ReturnType<typeof setTimeout> | undefined
1443
    const observedPromise = promise.then(
13✔
1444
      (value) => ({ kind: 'value' as const, value }),
10✔
1445
      (error) => ({ kind: 'error' as const, error })
1✔
1446
    )
1447
    try {
13✔
1448
      const result = await Promise.race([
13✔
1449
        observedPromise,
1450
        new Promise<T>((_, reject) => {
1451
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
13✔
1452
          timer.unref?.()
13✔
1453
        })
1454
      ])
1455
      if (result !== null && result !== undefined && typeof result === 'object' && 'kind' in result) {
10!
1456
        if (result.kind === 'error') {
10✔
1457
          throw result.error
1✔
1458
        }
1459
        return result.value
9✔
1460
      }
1461
      return result
×
1462
    } finally {
1463
      if (timer) {
13!
1464
        clearTimeout(timer)
13✔
1465
      }
1466
    }
1467
  }
1468

1469
  private shouldBroadcastL1Invalidation(): boolean {
1470
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
196✔
1471
  }
1472

1473
  private async observeOperation<T>(
1474
    name: string,
1475
    attributes: Record<string, unknown> | undefined,
1476
    execute: () => Promise<T>
1477
  ): Promise<T> {
1478
    const id = this.nextOperationId
540✔
1479
    this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER
540✔
1480
    this.emit('operation-start', { id, name, attributes })
540✔
1481

1482
    try {
540✔
1483
      const result = await execute()
540✔
1484
      this.emit('operation-end', {
498✔
1485
        id,
1486
        name,
1487
        attributes,
1488
        success: true,
1489
        result: result === null ? 'null' : undefined
498✔
1490
      })
1491
      return result
540✔
1492
    } catch (error) {
1493
      this.emit('operation-end', {
42✔
1494
        id,
1495
        name,
1496
        attributes,
1497
        success: false,
1498
        error
1499
      })
1500
      throw error
42✔
1501
    }
1502
  }
1503

1504
  private scheduleGenerationCleanup(generation: number): void {
1505
    this.maintenance.scheduleGenerationCleanup(
2✔
1506
      generation,
1507
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1508
      (failedGeneration, error) => {
1509
        this.logger.warn?.('generation-cleanup-error', {
1✔
1510
          generation: failedGeneration,
1511
          error: this.formatError(error)
1512
        })
1513
      }
1514
    )
1515
  }
1516

1517
  private async cleanupGeneration(generation: number): Promise<void> {
1518
    const prefix = `v${generation}:`
4✔
1519
    const batchSize = resolveGenerationCleanupBatchSize(this.options.generationCleanup)
4✔
1520
    const maxMatches = resolveGenerationCleanupMaxMatches(this.options.generationCleanup)
4✔
1521
    let batch: string[] = []
4✔
1522

1523
    const flushBatch = async (): Promise<void> => {
4✔
1524
      if (batch.length === 0) {
5✔
1525
        return
2✔
1526
      }
1527
      const keys = batch
3✔
1528
      batch = []
3✔
1529
      await this.deleteKeys(keys)
3✔
1530
      await this.publishInvalidation({
3✔
1531
        scope: 'keys',
1532
        keys,
1533
        sourceId: this.instanceId,
1534
        operation: 'invalidate'
1535
      })
1536
    }
1537

1538
    await this.keyDiscovery.forEachKeyWithPrefix(
4✔
1539
      prefix,
1540
      async (key) => {
1541
        batch.push(key)
4✔
1542
        if (batch.length >= batchSize) {
4✔
1543
          await flushBatch()
2✔
1544
        }
1545
      },
1546
      maxMatches
1547
    )
1548
    await flushBatch()
3✔
1549
  }
1550

1551
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1552
    this.maintenance.initializeWriteBehindTimer(
271✔
1553
      this.options.writeStrategy,
1554
      options,
1555
      this.flushWriteBehindQueue.bind(this)
1556
    )
1557
  }
1558

1559
  private shouldWriteBehind(layer: CacheLayer): boolean {
1560
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
425✔
1561
  }
1562

1563
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1564
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1565
  }
1566

1567
  private async flushWriteBehindQueue(): Promise<void> {
1568
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
28✔
1569
  }
1570

1571
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1572
    // Queue order is part of the stale-write fence: an older operation may
1573
    // perform compensating cleanup, so it must settle before a newer write.
1574
    const failures: unknown[] = []
3✔
1575
    for (const operation of batch) {
3✔
1576
      try {
4✔
1577
        await operation()
4✔
1578
      } catch (error) {
1579
        failures.push(error)
1✔
1580
      }
1581
    }
1582
    if (failures.length === 0) {
3✔
1583
      return
2✔
1584
    }
1585

1586
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1587
    this.logger.error?.('write-behind-flush-failure', {
1✔
1588
      failed: failures.length,
1589
      total: batch.length,
1590
      errors: failures.map((failure) => this.formatError(failure))
1✔
1591
    })
1592
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1593
  }
1594

1595
  private qualifyKey(key: string): string {
1596
    return qualifyGenerationKey(key, this.currentGeneration)
587✔
1597
  }
1598

1599
  private qualifyPattern(pattern: string): string {
1600
    return qualifyGenerationPattern(pattern, this.currentGeneration)
8✔
1601
  }
1602

1603
  private stripQualifiedKey(key: string): string {
1604
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1605
  }
1606

1607
  private validateConfiguration(): void {
1608
    if (
279✔
1609
      this.options.broadcastL1Invalidation !== undefined &&
284✔
1610
      this.options.publishSetInvalidation !== undefined &&
1611
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1612
    ) {
1613
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1614
    }
1615

1616
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
278✔
1617
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1618
    }
1619

1620
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
276✔
1621
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
276✔
1622
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
276✔
1623
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
276✔
1624
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
276✔
1625
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
276✔
1626
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
276✔
1627
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
276✔
1628
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
276✔
1629
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
276✔
1630
    if (this.options.snapshotMaxBytes !== false) {
276✔
1631
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
274✔
1632
    }
1633
    if (this.options.snapshotMaxEntries !== false) {
275✔
1634
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
274✔
1635
    }
1636
    if (this.options.invalidationMaxKeys !== false) {
275✔
1637
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
273✔
1638
    }
1639
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
275✔
1640
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
275✔
1641
    validateCircuitBreakerOptions(this.options.circuitBreaker)
275✔
1642
    if (typeof this.options.generationCleanup === 'object') {
275✔
1643
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
4✔
1644
      if (this.options.generationCleanup.maxMatches !== false) {
4!
1645
        validatePositiveNumber('generationCleanup.maxMatches', this.options.generationCleanup.maxMatches)
4✔
1646
      }
1647
    }
1648
    validatePositiveNumber('writeCoordination.maxPendingWrites', this.options.writeCoordination?.maxPendingWrites)
274✔
1649
    validatePositiveNumber('writeCoordination.maxActiveKeys', this.options.writeCoordination?.maxActiveKeys)
279✔
1650
    validatePositiveNumber(
279✔
1651
      'writeCoordination.maxPendingWritesPerKey',
1652
      this.options.writeCoordination?.maxPendingWritesPerKey
1653
    )
1654
    if (this.options.generation !== undefined) {
279✔
1655
      validateNonNegativeNumber('generation', this.options.generation)
7✔
1656
    }
1657
  }
1658

1659
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1660
    if (!options) {
503✔
1661
      return
329✔
1662
    }
1663

1664
    validateLayerNumberOption('options.ttl', options.ttl)
174✔
1665
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
174✔
1666
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
174✔
1667
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
174✔
1668
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
174✔
1669
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
174✔
1670
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
174✔
1671
    validateAdaptiveTtlOptions(options.adaptiveTtl)
174✔
1672
    validateCircuitBreakerOptions(options.circuitBreaker)
174✔
1673
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
174✔
1674
    validateTags(options.tags)
174✔
1675
    if (options.contextOptions && typeof options.contextOptions !== 'function') {
174✔
1676
      throw new Error('options.contextOptions must be a function.')
1✔
1677
    }
1678
  }
1679

1680
  private assertActive(operation: string): void {
1681
    if (this.isDisconnecting) {
1,198✔
1682
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1683
    }
1684
  }
1685

1686
  private async awaitStartup(operation: string): Promise<void> {
1687
    this.assertActive(operation)
582✔
1688
    await this.startup
582✔
1689
    this.assertActive(operation)
577✔
1690
  }
1691

1692
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1693
    return this.reader.readLayerEntry(layer, key)
39✔
1694
  }
1695

1696
  private scheduleBackgroundRefresh<T>(
1697
    key: string,
1698
    fetcher: CacheFetcher<T>,
1699
    options?: CacheGetOptions,
1700
    fetcherContext?: CacheFetcherContext<T>
1701
  ): void {
1702
    this.reader.runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext)
1✔
1703
  }
1704

1705
  private async applyFreshReadPolicies<T>(
1706
    key: string,
1707
    hit: {
1708
      found: true
1709
      value: T | null
1710
      stored: unknown
1711
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
1712
      layerIndex: number
1713
      layerName: string
1714
    },
1715
    options: CacheGetOptions | undefined,
1716
    fetcher?: CacheFetcher<T>
1717
  ): Promise<void> {
1718
    return this.reader.runApplyFreshReadPolicies(key, hit, options, fetcher)
2✔
1719
  }
1720

1721
  private shouldSkipLayer(layer: CacheLayer): boolean {
1722
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
1,104✔
1723
    const skip = shouldSkipDegradedLayer(degradedUntil)
1,104✔
1724
    if (!skip && degradedUntil !== undefined) {
1,104✔
1725
      this.layerDegradedUntil.delete(layer.name)
1✔
1726
    }
1727
    return skip
1,104✔
1728
  }
1729

1730
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1731
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
17✔
1732
    if (!recovery.degrade) {
17✔
1733
      throw error
4✔
1734
    }
1735

1736
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
13✔
1737
    this.metricsCollector.increment('degradedOperations')
13✔
1738
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
13✔
1739
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
17✔
1740
    return null
17✔
1741
  }
1742

1743
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1744
    if (this.isGracefulDegradationEnabled()) {
7✔
1745
      await this.handleLayerFailure(layer, operation, error)
5✔
1746
      return
5✔
1747
    }
1748

1749
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1750
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
7✔
1751
  }
1752

1753
  private isGracefulDegradationEnabled(): boolean {
1754
    return Boolean(this.options.gracefulDegradation)
9✔
1755
  }
1756

1757
  private recordCircuitFailure(
1758
    key: string,
1759
    breakerKey: string,
1760
    options: CacheCircuitBreakerOptions | undefined,
1761
    error: unknown
1762
  ): void {
1763
    if (!options) {
14✔
1764
      return
8✔
1765
    }
1766

1767
    this.circuitBreakerManager.recordFailure(breakerKey, options)
6✔
1768
    if (this.circuitBreakerManager.isOpen(breakerKey)) {
6!
1769
      this.metricsCollector.increment('circuitBreakerTrips')
6✔
1770
    }
1771
    this.emitError('fetch', { key, breakerKey, error: this.formatError(error) })
6✔
1772
  }
1773

1774
  private emitError(operation: string, context: Record<string, unknown>): void {
1775
    this.logger.error?.(operation, context)
26✔
1776
    if (this.listenerCount('error') > 0) {
26✔
1777
      this.emit('error', { operation, ...context })
9✔
1778
    }
1779
  }
1780

1781
  private snapshotMaxBytes(): number | false {
1782
    return this.options.snapshotMaxBytes === false
10✔
1783
      ? false
1784
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
17✔
1785
  }
1786

1787
  private snapshotMaxEntries(): number | false {
1788
    return this.options.snapshotMaxEntries === false
8✔
1789
      ? false
1790
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
13✔
1791
  }
1792

1793
  private invalidationMaxKeys(): number | false {
1794
    return this.options.invalidationMaxKeys === false
49✔
1795
      ? false
1796
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
88✔
1797
  }
1798
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc