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

flyingsquirrel0419 / layercache / 25992836483

17 May 2026 01:52PM UTC coverage: 95.708% (+0.07%) from 95.634%
25992836483

push

github

flyingsquirrel0419
Prepare 3.1.0 release

1800 of 1937 branches covered (92.93%)

Branch coverage included in aggregate %.

3240 of 3329 relevant lines covered (97.33%)

334.47 hits per line

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

93.88
/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
  planGenerationCleanupBatches,
13
  qualifyGenerationKey,
14
  qualifyGenerationPattern,
15
  resolveGenerationCleanupTarget,
16
  stripGenerationPrefix
17
} from './internal/CacheStackGeneration'
18
import { CacheStackInvalidationSupport } from './internal/CacheStackInvalidationSupport'
19
import { CacheStackLayerWriter, type CacheWriteKind } from './internal/CacheStackLayerWriter'
20
import { CacheStackMaintenance } from './internal/CacheStackMaintenance'
21
import { CacheStackReader } from './internal/CacheStackReader'
22
import {
23
  resolveRecoverableLayerFailure,
24
  shouldSkipLayer as shouldSkipDegradedLayer
25
} from './internal/CacheStackRuntimePolicy'
26
import { CacheStackSnapshotManager } from './internal/CacheStackSnapshotManager'
27
import {
28
  validateAdaptiveTtlOptions,
29
  validateCacheKey,
30
  validateCircuitBreakerOptions,
31
  validateContextEntryOptions,
32
  validateLayerNumberOption,
33
  validateNonNegativeNumber,
34
  validatePattern,
35
  validatePositiveNumber,
36
  validateRateLimitOptions,
37
  validateTag,
38
  validateTags,
39
  validateTtlPolicy
40
} from './internal/CacheStackValidation'
41
import { CircuitBreakerManager } from './internal/CircuitBreakerManager'
42
import { FetchRateLimiter } from './internal/FetchRateLimiter'
43
import { MetricsCollector } from './internal/MetricsCollector'
44
import { resolveStoredValue } from './internal/StoredValue'
45
import { TtlResolver } from './internal/TtlResolver'
46
import { TagIndex } from './invalidation/TagIndex'
47
import { JsonSerializer } from './serialization/JsonSerializer'
48
import { StampedeGuard } from './stampede/StampedeGuard'
49
import {
50
  type CacheAdaptiveTtlOptions,
51
  type CacheCircuitBreakerOptions,
52
  type CacheContextOptionsContext,
53
  type CacheEntryResult,
54
  type CacheEntryWriteKind,
55
  type CacheEntryWriteOptions,
56
  type CacheFetcher,
57
  type CacheFetcherContext,
58
  type CacheGetOptions,
59
  type CacheHealthCheckResult,
60
  type CacheHitRateSnapshot,
61
  type CacheInspectResult,
62
  type CacheLayer,
63
  type CacheLayerSetManyEntry,
64
  type CacheLogger,
65
  type CacheMGetEntry,
66
  type CacheMSetEntry,
67
  type CacheMetricsSnapshot,
68
  CacheMissError,
69
  type CacheSnapshotEntry,
70
  type CacheStackEvents,
71
  type CacheStackOptions,
72
  type CacheStatsSnapshot,
73
  type CacheTagIndex,
74
  type CacheTtlPolicy,
75
  type CacheWarmEntry,
76
  type CacheWarmOptions,
77
  type CacheWarmProgress,
78
  type CacheWrapOptions,
79
  type CacheWriteBehindOptions,
80
  type CacheWriteOptions,
81
  type InvalidationMessage,
82
  type LayerTtlMap
83
} from './types'
84

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

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

93
  constructor(enabled: boolean) {
94
    this.enabled = enabled
250✔
95
  }
96

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

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

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

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

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

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

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

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

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

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

184
    this.validateConfiguration()
261✔
185

186
    const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES
261✔
187
    this.ttlResolver = new TtlResolver({ maxProfileEntries })
262✔
188
    this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries })
262✔
189
    this.stampedeGuard = new StampedeGuard({
262✔
190
      maxInFlight: options.stampedeMaxInFlight,
191
      entryTimeoutMs: options.stampedeEntryTimeoutMs
192
    })
193
    this.currentGeneration = options.generation
262✔
194

195
    if (options.publishSetInvalidation !== undefined) {
262✔
196
      console.warn(
1✔
197
        '[layercache] CacheStackOptions.publishSetInvalidation is deprecated. ' + 'Use broadcastL1Invalidation instead.'
198
      )
199
    }
200

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

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

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

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

354
      for (let index = 0; index < this.layers.length; index += 1) {
6✔
355
        const layer = this.layers[index]
7✔
356
        if (!layer || this.shouldSkipLayer(layer)) {
7!
357
          continue
×
358
        }
359

360
        const readStart = performance.now()
7✔
361
        const stored = await this.readLayerEntry(layer, normalizedKey)
7✔
362
        this.metricsCollector.recordLatency(layer.name, performance.now() - readStart)
7✔
363
        if (stored === null) {
7✔
364
          this.metricsCollector.incrementLayer('missesByLayer', layer.name)
3✔
365
          continue
3✔
366
        }
367

368
        const resolved = resolveStoredValue<T>(stored)
4✔
369
        if (resolved.state === 'expired') {
4!
370
          await layer.delete(normalizedKey)
×
371
          continue
×
372
        }
373

374
        sawRetainableValue = true
4✔
375
        await this.tagIndex.touch(normalizedKey)
4✔
376
        await this.reader.backfill(normalizedKey, stored, index - 1)
4✔
377
        this.metricsCollector.increment('hits')
4✔
378
        if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
4!
379
          this.metricsCollector.increment('staleHits')
×
380
        }
381
        this.metricsCollector.incrementLayer('hitsByLayer', layer.name)
4✔
382
        this.logger.debug?.('hit', { key: normalizedKey, layer: layer.name, state: resolved.state })
4✔
383
        this.emit('hit', {
7✔
384
          key: normalizedKey,
385
          layer: layer.name,
386
          state: resolved.state as CacheStackEvents['hit']['state']
387
        })
388

389
        return {
7✔
390
          key: userKey,
391
          value: resolved.value,
392
          kind: resolved.envelope?.kind ?? 'value',
8✔
393
          state: resolved.state,
394
          layer: layer.name
395
        }
396
      }
397

398
      if (!sawRetainableValue) {
2!
399
        await this.tagIndex.remove(normalizedKey)
2✔
400
      }
401
      this.metricsCollector.increment('misses')
2✔
402
      this.logger.debug?.('miss', { key: normalizedKey, mode: 'getEntry' })
2✔
403
      this.emit('miss', { key: normalizedKey, mode: 'getEntry' })
6✔
404
      return null
6✔
405
    })
406
  }
407

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

421
  /**
422
   * Returns true if the given key exists and is not expired in any layer.
423
   */
424
  async has(key: string): Promise<boolean> {
425
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
9✔
426
    await this.awaitStartup('has')
9✔
427

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

457
  /**
458
   * Returns the remaining TTL in milliseconds for the key in the fastest layer
459
   * that has it, or null if the key is not found / has no TTL.
460
   */
461
  async ttl(key: string): Promise<number | null> {
462
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
4✔
463
    await this.awaitStartup('ttl')
4✔
464

465
    for (const layer of this.layers) {
4✔
466
      if (this.shouldSkipLayer(layer)) {
8✔
467
        continue
1✔
468
      }
469
      if (layer.ttl) {
7✔
470
        try {
6✔
471
          const remaining = await layer.ttl(normalizedKey)
6✔
472
          if (remaining !== null) {
4✔
473
            return remaining
3✔
474
          }
475
        } catch {
476
          // fall through
477
        }
478
      }
479
    }
480
    return null
1✔
481
  }
482

483
  /**
484
   * Stores a value in all cache layers. Overwrites any existing value.
485
   */
486
  async set<T>(key: string, value: T, options?: CacheWriteOptions): Promise<void> {
487
    await this.observeOperation('layercache.set', { 'layercache.key': String(key ?? '') }, async () => {
127✔
488
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
127✔
489
      this.validateWriteOptions(options)
127✔
490
      await this.awaitStartup('set')
127✔
491
      await this.storeEntry(normalizedKey, 'value', value, options)
122✔
492
    })
493
  }
494

495
  /**
496
   * Deletes the key from all layers and publishes an invalidation message.
497
   */
498
  async delete(key: string): Promise<void> {
499
    await this.observeOperation('layercache.delete', { 'layercache.key': String(key ?? '') }, async () => {
9✔
500
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
9✔
501
      await this.awaitStartup('delete')
9✔
502
      await this.deleteKeys([normalizedKey])
8✔
503
      await this.publishInvalidation({
8✔
504
        scope: 'key',
505
        keys: [normalizedKey],
506
        sourceId: this.instanceId,
507
        operation: 'delete'
508
      })
509
    })
510
  }
511

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

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

547
  /**
548
   * Alias for `delete(key)` that matches the `invalidateBy*` API family.
549
   */
550
  async invalidateByKey(key: string): Promise<void> {
551
    await this.delete(key)
2✔
552
  }
553

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

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

578
  /**
579
   * Marks multiple exact keys expired without deleting their stale values.
580
   */
581
  async expireByKeys(keys: string[]): Promise<void> {
582
    await this.observeOperation('layercache.expire_by_keys', undefined, async () => {
3✔
583
      if (keys.length === 0) {
3✔
584
        return
1✔
585
      }
586

587
      const normalizedKeys = keys.map((k) => validateCacheKey(k))
3✔
588
      const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key))
3✔
589
      await this.awaitStartup('expireByKeys')
2✔
590
      await this.expireKeys(cacheKeys)
2✔
591
      await this.publishInvalidation({
2✔
592
        scope: 'keys',
593
        keys: cacheKeys,
594
        sourceId: this.instanceId,
595
        operation: 'expire'
596
      })
597
    })
598
  }
599

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

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

628
        return Promise.all(
2✔
629
          normalizedEntries.map((entry) => {
630
            const optionsSignature = serializeOptions(entry.options)
4✔
631
            const existing = pendingReads.get(entry.key)
4✔
632
            if (!existing) {
4✔
633
              const promise = this.reader.getPrepared(entry.key, entry.fetch, entry.options)
2✔
634
              pendingReads.set(entry.key, {
2✔
635
                promise,
636
                fetch: entry.fetch,
637
                optionsSignature
638
              })
639
              return promise
2✔
640
            }
641

642
            if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2!
643
              const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key
2!
644
              throw new Error(`mget received conflicting entries for key "${displayKey}".`)
2✔
645
            }
646

647
            return existing.promise
×
648
          })
649
        )
650
      }
651

652
      await this.awaitStartup('mget')
6✔
653
      const pending = new Set<string>()
6✔
654
      const indexesByKey = new Map<string, number[]>()
6✔
655
      const resultsByKey = new Map<string, T | null>()
6✔
656

657
      for (let index = 0; index < normalizedEntries.length; index += 1) {
6✔
658
        const entry = normalizedEntries[index]
14✔
659
        if (!entry) continue
14!
660
        const key = entry.key
14✔
661
        const indexes = indexesByKey.get(key) ?? []
14✔
662
        indexes.push(index)
14✔
663
        indexesByKey.set(key, indexes)
14✔
664
        pending.add(key)
14✔
665
      }
666

667
      for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
6✔
668
        const layer = this.layers[layerIndex]
6✔
669
        if (!layer || this.shouldSkipLayer(layer)) continue
6!
670
        const keys = [...pending]
6✔
671
        if (keys.length === 0) {
6!
672
          break
×
673
        }
674

675
        const values = layer.getMany
6!
676
          ? await layer.getMany(keys)
677
          : await Promise.all(keys.map((key) => this.reader.readLayerEntry(layer, key)))
×
678

679
        for (let offset = 0; offset < values.length; offset += 1) {
×
680
          const key = keys[offset]
13✔
681
          const stored = values[offset]
13✔
682
          if (!key || stored === null) {
13✔
683
            continue
2✔
684
          }
685

686
          const resolved = resolveStoredValue<T>(stored)
11✔
687
          if (resolved.state === 'expired') {
11✔
688
            await layer.delete(key)
1✔
689
            continue
1✔
690
          }
691

692
          if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
10!
693
            this.metricsCollector.increment('staleHits', indexesByKey.get(key)?.length ?? 1)
×
694
          }
695

696
          await this.tagIndex.touch(key)
10✔
697
          await this.reader.backfill(key, stored, layerIndex - 1)
10✔
698
          resultsByKey.set(key, resolved.value)
10✔
699
          pending.delete(key)
10✔
700
          this.metricsCollector.increment('hits', indexesByKey.get(key)?.length ?? 1)
10!
701
        }
702
      }
703

704
      if (pending.size > 0) {
6✔
705
        for (const key of pending) {
2✔
706
          await this.tagIndex.remove(key)
3✔
707
          this.metricsCollector.increment('misses', indexesByKey.get(key)?.length ?? 1)
3!
708
        }
709
      }
710

711
      return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null)
14✔
712
    })
713
  }
714

715
  /**
716
   * Writes many entries concurrently using each layer's bulk write fast path
717
   * when available.
718
   */
719
  async mset<T>(entries: CacheMSetEntry<T>[]): Promise<void> {
720
    await this.observeOperation('layercache.mset', undefined, async () => {
14✔
721
      this.assertActive('mset')
14✔
722
      const normalizedEntries = entries.map((entry) => ({
32✔
723
        ...entry,
724
        key: this.qualifyKey(validateCacheKey(entry.key))
725
      }))
726
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
32✔
727
      await this.awaitStartup('mset')
14✔
728
      await this.writeBatch(normalizedEntries)
14✔
729
    })
730
  }
731

732
  /**
733
   * Pre-populates cache entries by running their fetchers with bounded
734
   * concurrency. Higher-priority entries run first.
735
   */
736
  async warm(entries: CacheWarmEntry[], options: CacheWarmOptions = {}): Promise<void> {
4✔
737
    this.assertActive('warm')
4✔
738
    const concurrency = Math.max(1, options.concurrency ?? 4)
4✔
739
    const total = entries.length
4✔
740
    let completed = 0
4✔
741
    const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
4!
742
    const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
4!
743
      while (queue.length > 0) {
4✔
744
        const entry = queue.shift()
6✔
745
        if (!entry) {
6!
746
          return
×
747
        }
748

749
        let success = false
6✔
750
        try {
6✔
751
          await this.get(entry.key, entry.fetcher, entry.options)
6✔
752
          this.emit('warm', { key: entry.key })
4✔
753
          success = true
4✔
754
        } catch (error) {
755
          this.emitError('warm', { key: entry.key, error: this.formatError(error) })
2✔
756
          if (!options.continueOnError) {
2✔
757
            throw error
1✔
758
          }
759
        } finally {
760
          completed += 1
6✔
761
          const progress: CacheWarmProgress = { completed, total, key: entry.key, success }
6✔
762
          options.onProgress?.(progress)
6✔
763
        }
764
      }
765
    })
766

767
    await Promise.all(workers)
4✔
768
  }
769

770
  /**
771
   * Returns a cached version of `fetcher`. The cache key is derived from
772
   * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
773
   */
774
  wrap<TArgs extends unknown[], TResult>(
775
    prefix: string,
776
    fetcher: (...args: TArgs) => Promise<TResult>,
777
    options: CacheWrapOptions<TArgs> = {}
9✔
778
  ): (...args: TArgs) => Promise<TResult | null> {
779
    return (...args: TArgs) => {
9✔
780
      const suffix = options.keyResolver
16✔
781
        ? options.keyResolver(...args)
782
        : args.map((argument) => serializeKeyPart(argument)).join(':')
9✔
783
      const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix
16!
784
      return this.get<TResult>(key, () => fetcher(...args), options)
16✔
785
    }
786
  }
787

788
  /**
789
   * Creates a `CacheNamespace` that automatically prefixes all keys with
790
   * `prefix:`. Useful for multi-tenant or module-level isolation.
791
   */
792
  namespace(prefix: string): CacheNamespace {
793
    validateNamespaceKey(prefix)
47✔
794
    return new CacheNamespace(this, prefix)
47✔
795
  }
796

797
  /**
798
   * Deletes every key currently associated with `tag` and broadcasts an
799
   * invalidation message.
800
   */
801
  async invalidateByTag(tag: string): Promise<void> {
802
    await this.observeOperation('layercache.invalidate_by_tag', undefined, async () => {
8✔
803
      validateTag(tag)
8✔
804
      await this.awaitStartup('invalidateByTag')
8✔
805
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
7✔
806
      await this.deleteKeys(keys)
6✔
807
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
808
    })
809
  }
810

811
  /**
812
   * Marks every key associated with `tag` as expired while preserving stale
813
   * windows for stale serving.
814
   */
815
  async expireByTag(tag: string): Promise<void> {
816
    await this.observeOperation('layercache.expire_by_tag', undefined, async () => {
4✔
817
      validateTag(tag)
4✔
818
      await this.awaitStartup('expireByTag')
4✔
819
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
4✔
820
      await this.expireKeys(keys)
4✔
821
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
4✔
822
    })
823
  }
824

825
  /**
826
   * Deletes keys associated with any or all of the provided tags and broadcasts
827
   * an invalidation message.
828
   */
829
  async invalidateByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
5✔
830
    await this.observeOperation('layercache.invalidate_by_tags', undefined, async () => {
5✔
831
      if (tags.length === 0) {
5✔
832
        return
1✔
833
      }
834

835
      validateTags(tags)
4✔
836
      await this.awaitStartup('invalidateByTags')
4✔
837
      const keysByTag = await Promise.all(
4✔
838
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
7✔
839
      )
840
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
3✔
841
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
5✔
842

843
      await this.deleteKeys(keys)
5✔
844
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
845
    })
846
  }
847

848
  /**
849
   * Marks keys associated with any or all of the provided tags as expired while
850
   * preserving stale windows for stale serving.
851
   */
852
  async expireByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
3✔
853
    await this.observeOperation('layercache.expire_by_tags', undefined, async () => {
3✔
854
      if (tags.length === 0) {
3✔
855
        return
1✔
856
      }
857

858
      validateTags(tags)
2✔
859
      await this.awaitStartup('expireByTags')
2✔
860
      const keysByTag = await Promise.all(
2✔
861
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
4✔
862
      )
863
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
2!
864
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
3✔
865

866
      await this.expireKeys(keys)
3✔
867
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
868
    })
869
  }
870

871
  /**
872
   * Deletes keys matching a wildcard pattern such as `user:*`.
873
   */
874
  async invalidateByPattern(pattern: string): Promise<void> {
875
    await this.observeOperation('layercache.invalidate_by_pattern', undefined, async () => {
5✔
876
      validatePattern(pattern)
5✔
877
      await this.awaitStartup('invalidateByPattern')
5✔
878
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
5✔
879
        this.qualifyPattern(pattern),
880
        this.invalidationMaxKeys()
881
      )
882
      await this.deleteKeys(keys)
4✔
883
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
4✔
884
    })
885
  }
886

887
  /**
888
   * Marks keys matching a wildcard pattern as expired while preserving stale
889
   * windows for stale serving.
890
   */
891
  async expireByPattern(pattern: string): Promise<void> {
892
    await this.observeOperation('layercache.expire_by_pattern', undefined, async () => {
3✔
893
      validatePattern(pattern)
3✔
894
      await this.awaitStartup('expireByPattern')
3✔
895
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
3✔
896
        this.qualifyPattern(pattern),
897
        this.invalidationMaxKeys()
898
      )
899
      await this.expireKeys(keys)
3✔
900
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
3✔
901
    })
902
  }
903

904
  /**
905
   * Deletes keys that start with the provided prefix.
906
   */
907
  async invalidateByPrefix(prefix: string): Promise<void> {
908
    await this.observeOperation('layercache.invalidate_by_prefix', undefined, async () => {
7✔
909
      await this.awaitStartup('invalidateByPrefix')
7✔
910
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
7✔
911
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
7✔
912
      await this.deleteKeys(keys)
6✔
913
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
914
    })
915
  }
916

917
  /**
918
   * Marks keys that start with the provided prefix as expired while preserving
919
   * stale windows for stale serving.
920
   */
921
  async expireByPrefix(prefix: string): Promise<void> {
922
    await this.observeOperation('layercache.expire_by_prefix', undefined, async () => {
3✔
923
      await this.awaitStartup('expireByPrefix')
3✔
924
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
3✔
925
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
3✔
926
      await this.expireKeys(keys)
2✔
927
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
928
    })
929
  }
930

931
  /**
932
   * Returns cumulative cache metrics since startup or the last `resetMetrics()`.
933
   */
934
  getMetrics(): CacheMetricsSnapshot {
935
    return this.metricsCollector.snapshot
55✔
936
  }
937

938
  /**
939
   * Runs an operation while collecting only the metrics emitted by its async context.
940
   * Used by namespaces so metrics tracking does not serialize the operation itself.
941
   */
942
  async captureMetrics<T>(operation: () => Promise<T>): Promise<{ result: T; metrics: CacheMetricsSnapshot }> {
943
    return this.metricsCollector.capture(operation)
101✔
944
  }
945

946
  /**
947
   * Returns metrics plus layer degradation state and active background refresh count.
948
   */
949
  getStats(): CacheStatsSnapshot {
950
    return {
29✔
951
      metrics: this.getMetrics(),
952
      layers: this.layers.map((layer) => ({
33✔
953
        name: layer.name,
954
        isLocal: Boolean(layer.isLocal),
955
        degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
62✔
956
      })),
957
      backgroundRefreshes: this.reader.activeRefreshCount
958
    }
959
  }
960

961
  /**
962
   * Resets cumulative metrics counters.
963
   */
964
  resetMetrics(): void {
965
    this.metricsCollector.reset()
1✔
966
  }
967

968
  /**
969
   * Returns computed hit-rate statistics (overall and per-layer).
970
   */
971
  getHitRate(): CacheHitRateSnapshot {
972
    return this.metricsCollector.hitRate()
6✔
973
  }
974

975
  /**
976
   * Runs each layer's `ping()` hook when available and returns per-layer health
977
   * and latency information.
978
   */
979
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
980
    await this.startup
2✔
981

982
    return Promise.all(
2✔
983
      this.layers.map(async (layer) => {
984
        const startedAt = performance.now()
4✔
985
        try {
4✔
986
          const healthy = layer.ping ? await layer.ping() : true
4✔
987
          return {
4✔
988
            layer: layer.name,
989
            healthy,
990
            latencyMs: performance.now() - startedAt
991
          }
992
        } catch (error) {
993
          return {
1✔
994
            layer: layer.name,
995
            healthy: false,
996
            latencyMs: performance.now() - startedAt,
997
            error: this.formatError(error)
998
          }
999
        }
1000
      })
1001
    )
1002
  }
1003

1004
  /**
1005
   * Rotates the active generation prefix used for all future cache keys.
1006
   * Previous-generation keys remain in the underlying layers until they expire,
1007
   * unless `generationCleanup` is enabled to prune them in the background.
1008
   */
1009
  bumpGeneration(nextGeneration?: number): number {
1010
    const current = this.currentGeneration ?? 0
5!
1011
    const previousGeneration = this.currentGeneration
5✔
1012
    const updatedGeneration = nextGeneration ?? current + 1
5✔
1013
    const generationToCleanup = resolveGenerationCleanupTarget({
5✔
1014
      previousGeneration,
1015
      nextGeneration: updatedGeneration,
1016
      generationCleanup: this.options.generationCleanup
1017
    })
1018

1019
    this.currentGeneration = updatedGeneration
5✔
1020
    if (generationToCleanup !== null) {
5✔
1021
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
1022
    }
1023

1024
    return this.currentGeneration
5✔
1025
  }
1026

1027
  /**
1028
   * Returns the active generation prefix number used for future cache keys.
1029
   */
1030
  getGeneration(): number | undefined {
1031
    return this.currentGeneration
3✔
1032
  }
1033

1034
  /**
1035
   * Returns detailed metadata about a single cache key: which layers contain it,
1036
   * remaining fresh/stale/error TTLs, and associated tags.
1037
   * Returns `null` if the key does not exist in any layer.
1038
   */
1039
  async inspect(key: string): Promise<CacheInspectResult | null> {
1040
    const userKey = validateCacheKey(key)
30✔
1041
    const normalizedKey = this.qualifyKey(userKey)
30✔
1042
    await this.awaitStartup('inspect')
30✔
1043

1044
    const foundInLayers: string[] = []
30✔
1045
    let freshTtlMs: number | null = null
30✔
1046
    let staleTtlMs: number | null = null
30✔
1047
    let errorTtlMs: number | null = null
30✔
1048
    let isStale = false
30✔
1049

1050
    for (const layer of this.layers) {
30✔
1051
      if (this.shouldSkipLayer(layer)) {
31!
1052
        continue
×
1053
      }
1054
      const stored = await this.readLayerEntry(layer, normalizedKey)
31✔
1055
      if (stored === null) {
31✔
1056
        continue
1✔
1057
      }
1058

1059
      const resolved = resolveStoredValue(stored)
30✔
1060
      if (resolved.state === 'expired') {
30!
1061
        continue
×
1062
      }
1063

1064
      foundInLayers.push(layer.name)
30✔
1065

1066
      // Take TTL info from the first (fastest) layer that has it
1067
      if (foundInLayers.length === 1 && resolved.envelope) {
30✔
1068
        const now = Date.now()
29✔
1069
        freshTtlMs =
29✔
1070
          resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.freshUntil - now)) : null
29!
1071
        staleTtlMs =
29✔
1072
          resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.staleUntil - now)) : null
29✔
1073
        errorTtlMs =
29✔
1074
          resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.errorUntil - now)) : null
29✔
1075
        isStale = resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error'
29✔
1076
      }
1077
    }
1078

1079
    if (foundInLayers.length === 0) {
30✔
1080
      return null
1✔
1081
    }
1082

1083
    const tags = await this.getTagsForKey(normalizedKey)
29✔
1084

1085
    return { key: userKey, foundInLayers, freshTtlMs, staleTtlMs, errorTtlMs, isStale, tags }
29✔
1086
  }
1087

1088
  /**
1089
   * Exports cache entries from configured layers for process-local snapshots.
1090
   */
1091
  async exportState(): Promise<CacheSnapshotEntry[]> {
1092
    await this.awaitStartup('exportState')
2✔
1093
    return this.snapshots.exportState(this.snapshotMaxEntries())
2✔
1094
  }
1095

1096
  /**
1097
   * Imports entries produced by `exportState()` into the configured layers.
1098
   */
1099
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
1100
    await this.awaitStartup('importState')
1✔
1101
    await this.snapshots.importState(entries)
1✔
1102
  }
1103

1104
  /**
1105
   * Writes a snapshot file containing current cache entries.
1106
   */
1107
  async persistToFile(filePath: string): Promise<void> {
1108
    this.assertActive('persistToFile')
4✔
1109
    await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries())
4✔
1110
  }
1111

1112
  /**
1113
   * Restores cache entries from a snapshot file.
1114
   */
1115
  async restoreFromFile(filePath: string): Promise<void> {
1116
    this.assertActive('restoreFromFile')
8✔
1117
    await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes())
8✔
1118
  }
1119

1120
  /**
1121
   * Flushes background work, unsubscribes from buses, disposes timers, and then
1122
   * disposes each layer that provides `dispose()`.
1123
   */
1124
  async disconnect(): Promise<void> {
1125
    if (!this.disconnectPromise) {
28!
1126
      this.isDisconnecting = true
28✔
1127
      this.disconnectPromise = (async () => {
28✔
1128
        await this.startup
28✔
1129
        await this.unsubscribeInvalidation?.()
28✔
1130
        await this.flushWriteBehindQueue()
28✔
1131
        await this.maintenance.waitForGenerationCleanup()
28✔
1132
        this.reader.abortAllRefreshes()
28✔
1133
        await Promise.allSettled(
28✔
1134
          this.reader.getAllRefreshPromises().map((promise) => {
1135
            let timer: ReturnType<typeof setTimeout> | undefined
1136
            return Promise.race([
×
1137
              promise,
1138
              new Promise<void>((resolve) => {
1139
                timer = setTimeout(resolve, 5_000)
×
1140
                timer.unref?.()
×
1141
              })
1142
            ]).finally(() => {
1143
              if (timer) clearTimeout(timer)
×
1144
            })
1145
          })
1146
        )
1147
        this.maintenance.disposeWriteBehindTimer()
28✔
1148
        this.fetchRateLimiter.dispose()
28✔
1149
        await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()))
42✔
1150
      })()
1151
    }
1152

1153
    await this.disconnectPromise
28✔
1154
  }
1155

1156
  private async initialize(): Promise<void> {
1157
    if (!this.options.invalidationBus) {
257✔
1158
      return
239✔
1159
    }
1160

1161
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
18✔
1162
      await this.handleInvalidationMessage(message)
12✔
1163
    })
1164
  }
1165

1166
  private async storeEntry(
1167
    key: string,
1168
    kind: CacheWriteKind,
1169
    value: unknown,
1170
    options?: CacheWriteOptions
1171
  ): Promise<void> {
1172
    const resolvedOptions = this.resolveContextOptions(key, kind, value, options)
180✔
1173
    const clearEpoch = this.maintenance.currentClearEpoch()
180✔
1174
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
180✔
1175
    await this.layerWriter.writeAcrossLayers(key, kind, value, resolvedOptions)
180✔
1176
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
175!
1177
      return
×
1178
    }
1179
    if (resolvedOptions?.tags) {
175✔
1180
      await this.tagIndex.track(key, resolvedOptions.tags)
29✔
1181
    } else {
1182
      await this.tagIndex.touch(key)
146✔
1183
    }
1184

1185
    this.metricsCollector.increment('sets')
175✔
1186
    this.logger.debug?.('set', { key, kind, tags: resolvedOptions?.tags })
175✔
1187
    this.emit('set', { key, kind: kind as string, tags: resolvedOptions?.tags })
180✔
1188
    if (this.shouldBroadcastL1Invalidation()) {
180✔
1189
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
1190
    }
1191
  }
1192

1193
  private async writeBatch(
1194
    entries: Array<{ key: string; value: unknown; options?: CacheWriteOptions }>
1195
  ): Promise<void> {
1196
    const resolvedEntries = entries.map((entry) => ({
32✔
1197
      ...entry,
1198
      options: this.resolveContextOptions(entry.key, 'value', entry.value, entry.options)
1199
    }))
1200
    const { clearEpoch, entryEpochs } = await this.layerWriter.writeBatch(resolvedEntries)
14✔
1201
    if (clearEpoch !== this.maintenance.currentClearEpoch()) {
13!
1202
      return
×
1203
    }
1204

1205
    for (const entry of resolvedEntries) {
13✔
1206
      if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
30!
1207
        continue
×
1208
      }
1209
      if (entry.options?.tags) {
30✔
1210
        await this.tagIndex.track(entry.key, entry.options.tags)
2✔
1211
      } else {
1212
        await this.tagIndex.touch(entry.key)
28✔
1213
      }
1214

1215
      this.metricsCollector.increment('sets')
30✔
1216
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
30✔
1217
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
30✔
1218
    }
1219

1220
    if (this.shouldBroadcastL1Invalidation()) {
13✔
1221
      await this.publishInvalidation({
1✔
1222
        scope: 'keys',
1223
        keys: entries.map((entry) => entry.key),
2✔
1224
        sourceId: this.instanceId,
1225
        operation: 'write'
1226
      })
1227
    }
1228
  }
1229

1230
  private resolveFreshTtl(
1231
    key: string,
1232
    layerName: string,
1233
    kind: CacheWriteKind,
1234
    options: CacheWriteOptions | undefined,
1235
    fallbackTtl: number | undefined,
1236
    value: unknown
1237
  ): number | undefined {
1238
    return this.ttlResolver.resolveFreshTtl(
228✔
1239
      key,
1240
      layerName,
1241
      kind,
1242
      options,
1243
      fallbackTtl,
1244
      this.options.negativeTtl,
1245
      undefined,
1246
      value
1247
    )
1248
  }
1249

1250
  private resolveLayerMs(
1251
    layerName: string,
1252
    override: number | LayerTtlMap | undefined,
1253
    globalDefault?: number | LayerTtlMap,
1254
    fallback?: number
1255
  ): number | undefined {
1256
    return this.ttlResolver.resolveLayerMs(layerName, override, globalDefault, fallback)
538✔
1257
  }
1258

1259
  private resolveContextOptions(
1260
    key: string,
1261
    kind: CacheEntryWriteKind,
1262
    value: unknown,
1263
    options: CacheWriteOptions | undefined
1264
  ): CacheWriteOptions | undefined {
1265
    if (!options?.contextOptions) {
212✔
1266
      return options
204✔
1267
    }
1268

1269
    const { contextOptions, ...baseOptions } = options
8✔
1270
    let overrides: CacheEntryWriteOptions | undefined
1271
    try {
8✔
1272
      overrides = contextOptions({ key, value, kind } as CacheContextOptionsContext)
8✔
1273
    } catch (error) {
1274
      throw new Error(`options.contextOptions() failed for key "${key}": ${this.formatError(error)}`)
1✔
1275
    }
1276
    if (!overrides) {
7✔
1277
      return baseOptions
1✔
1278
    }
1279
    if (!this.isPlainObject(overrides)) {
6✔
1280
      throw new Error(
3✔
1281
        `options.contextOptions() must return a plain object or undefined for key "${key}". Async resolvers are not supported.`
1282
      )
1283
    }
1284

1285
    try {
3✔
1286
      validateContextEntryOptions('options.contextOptions()', overrides)
3✔
1287
    } catch (error) {
1288
      throw new Error(
1✔
1289
        `options.contextOptions() returned invalid entry options for key "${key}": ${this.formatError(error)}`
1290
      )
1291
    }
1292
    return {
2✔
1293
      ...baseOptions,
1294
      ...overrides
1295
    }
1296
  }
1297

1298
  private isPlainObject(value: unknown): value is Record<string, unknown> {
1299
    if (!value || typeof value !== 'object' || Array.isArray(value)) {
6✔
1300
      return false
2✔
1301
    }
1302

1303
    const prototype = Object.getPrototypeOf(value)
4✔
1304
    return prototype === Object.prototype || prototype === null
4✔
1305
  }
1306

1307
  private async deleteKeys(keys: string[]): Promise<void> {
1308
    if (keys.length === 0) {
32✔
1309
      return
4✔
1310
    }
1311

1312
    this.maintenance.bumpKeyEpochs(keys)
28✔
1313
    await this.invalidation.deleteKeysFromLayers(this.layers, keys)
28✔
1314

1315
    for (const key of keys) {
28✔
1316
      await this.tagIndex.remove(key)
36✔
1317
      this.ttlResolver.deleteProfile(key)
36✔
1318
      this.circuitBreakerManager.delete(`key:${key}`)
36✔
1319
    }
1320

1321
    this.metricsCollector.increment('deletes', keys.length)
28✔
1322
    this.metricsCollector.increment('invalidations')
28✔
1323
    this.logger.debug?.('delete', { keys })
28✔
1324
    this.emit('delete', { keys })
32✔
1325
  }
1326

1327
  private async expireKeys(keys: string[]): Promise<void> {
1328
    if (keys.length === 0) {
15✔
1329
      return
1✔
1330
    }
1331

1332
    this.maintenance.bumpKeyEpochs(keys)
14✔
1333
    const foundKeys = await this.expireKeysInLayers(keys, this.layers)
14✔
1334

1335
    for (const key of keys) {
14✔
1336
      if (foundKeys.has(key)) {
18✔
1337
        continue
17✔
1338
      }
1339

1340
      await this.tagIndex.remove(key)
1✔
1341
      this.ttlResolver.deleteProfile(key)
1✔
1342
      this.circuitBreakerManager.delete(`key:${key}`)
1✔
1343
    }
1344

1345
    this.metricsCollector.increment('invalidations')
14✔
1346
    this.logger.debug?.('expire', { keys })
14✔
1347
    this.emit('expire', { keys })
15✔
1348
  }
1349

1350
  private async expireKeysInLayers(keys: string[], layers: CacheLayer[]): Promise<Set<string>> {
1351
    if (keys.length === 0) {
16✔
1352
      return new Set()
1✔
1353
    }
1354

1355
    return this.invalidation.expireKeysInLayers(layers, keys)
15✔
1356
  }
1357

1358
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1359
    if (!this.options.invalidationBus) {
54✔
1360
      return
47✔
1361
    }
1362

1363
    await this.options.invalidationBus.publish(message)
7✔
1364
  }
1365

1366
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1367
    if (message.sourceId === this.instanceId) {
15✔
1368
      return
7✔
1369
    }
1370

1371
    const localLayers = this.layers.filter((layer) => layer.isLocal)
12✔
1372
    if (message.scope === 'clear') {
8✔
1373
      this.maintenance.beginClearEpoch()
2✔
1374
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1375
      await this.tagIndex.clear()
2✔
1376
      this.ttlResolver.clearProfiles()
2✔
1377
      this.circuitBreakerManager.clear()
2✔
1378
      return
2✔
1379
    }
1380

1381
    const keys = message.keys ?? []
6!
1382
    this.maintenance.bumpKeyEpochs(keys)
15✔
1383
    if (message.operation === 'expire') {
15✔
1384
      await this.expireKeysInLayers(keys, localLayers)
1✔
1385
      return
1✔
1386
    }
1387

1388
    await this.invalidation.deleteKeysFromLayers(localLayers, keys)
5✔
1389

1390
    if (message.operation !== 'write') {
5✔
1391
      for (const key of keys) {
2✔
1392
        await this.tagIndex.remove(key)
3✔
1393
        this.ttlResolver.deleteProfile(key)
3✔
1394
        this.circuitBreakerManager.delete(`key:${key}`)
3✔
1395
      }
1396
    }
1397
  }
1398

1399
  private async getTagsForKey(key: string): Promise<string[]> {
1400
    if (this.tagIndex.tagsForKey) {
31✔
1401
      return this.tagIndex.tagsForKey(key)
30✔
1402
    }
1403
    return []
1✔
1404
  }
1405

1406
  private formatError(error: unknown): string {
1407
    if (error instanceof Error) {
55✔
1408
      return error.message
54✔
1409
    }
1410

1411
    return String(error)
1✔
1412
  }
1413

1414
  private sleep(ms: number): Promise<void> {
1415
    return new Promise((resolve) => setTimeout(resolve, ms))
4✔
1416
  }
1417

1418
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1419
    if (timeoutMs <= 0) {
15✔
1420
      return promise
1✔
1421
    }
1422

1423
    let timer: ReturnType<typeof setTimeout> | undefined
1424
    const observedPromise = promise.then(
14✔
1425
      (value) => ({ kind: 'value' as const, value }),
9✔
1426
      (error) => ({ kind: 'error' as const, error })
2✔
1427
    )
1428
    try {
14✔
1429
      const result = await Promise.race([
14✔
1430
        observedPromise,
1431
        new Promise<T>((_, reject) => {
1432
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
14✔
1433
          timer.unref?.()
14✔
1434
        })
1435
      ])
1436
      if (result !== null && result !== undefined && typeof result === 'object' && 'kind' in result) {
10!
1437
        if (result.kind === 'error') {
10✔
1438
          throw result.error
1✔
1439
        }
1440
        return result.value
9✔
1441
      }
1442
      return result
×
1443
    } finally {
1444
      if (timer) {
14!
1445
        clearTimeout(timer)
14✔
1446
      }
1447
    }
1448
  }
1449

1450
  private shouldBroadcastL1Invalidation(): boolean {
1451
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
188✔
1452
  }
1453

1454
  private async observeOperation<T>(
1455
    name: string,
1456
    attributes: Record<string, unknown> | undefined,
1457
    execute: () => Promise<T>
1458
  ): Promise<T> {
1459
    const id = this.nextOperationId
524✔
1460
    this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER
524✔
1461
    this.emit('operation-start', { id, name, attributes })
524✔
1462

1463
    try {
524✔
1464
      const result = await execute()
524✔
1465
      this.emit('operation-end', {
482✔
1466
        id,
1467
        name,
1468
        attributes,
1469
        success: true,
1470
        result: result === null ? 'null' : undefined
482✔
1471
      })
1472
      return result
524✔
1473
    } catch (error) {
1474
      this.emit('operation-end', {
42✔
1475
        id,
1476
        name,
1477
        attributes,
1478
        success: false,
1479
        error
1480
      })
1481
      throw error
42✔
1482
    }
1483
  }
1484

1485
  private scheduleGenerationCleanup(generation: number): void {
1486
    this.maintenance.scheduleGenerationCleanup(
2✔
1487
      generation,
1488
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1489
      (failedGeneration, error) => {
1490
        this.logger.warn?.('generation-cleanup-error', {
1✔
1491
          generation: failedGeneration,
1492
          error: this.formatError(error)
1493
        })
1494
      }
1495
    )
1496
  }
1497

1498
  private async cleanupGeneration(generation: number): Promise<void> {
1499
    const prefix = `v${generation}:`
3✔
1500
    const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix)
3✔
1501
    for (const batch of planGenerationCleanupBatches(keys, this.options.generationCleanup)) {
2✔
1502
      await this.deleteKeys(batch)
1✔
1503
      await this.publishInvalidation({
1✔
1504
        scope: 'keys',
1505
        keys: batch,
1506
        sourceId: this.instanceId,
1507
        operation: 'invalidate'
1508
      })
1509
    }
1510
  }
1511

1512
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1513
    this.maintenance.initializeWriteBehindTimer(
257✔
1514
      this.options.writeStrategy,
1515
      options,
1516
      this.flushWriteBehindQueue.bind(this)
1517
    )
1518
  }
1519

1520
  private shouldWriteBehind(layer: CacheLayer): boolean {
1521
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
211✔
1522
  }
1523

1524
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1525
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1526
  }
1527

1528
  private async flushWriteBehindQueue(): Promise<void> {
1529
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
28✔
1530
  }
1531

1532
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1533
    const results = await Promise.allSettled(batch.map((operation) => operation()))
4✔
1534
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4✔
1535
    if (failures.length === 0) {
3✔
1536
      return
2✔
1537
    }
1538

1539
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1540
    this.logger.error?.('write-behind-flush-failure', {
1✔
1541
      failed: failures.length,
1542
      total: batch.length,
1543
      errors: failures.map((failure) => this.formatError(failure.reason))
1✔
1544
    })
1545
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1546
  }
1547

1548
  private qualifyKey(key: string): string {
1549
    return qualifyGenerationKey(key, this.currentGeneration)
571✔
1550
  }
1551

1552
  private qualifyPattern(pattern: string): string {
1553
    return qualifyGenerationPattern(pattern, this.currentGeneration)
8✔
1554
  }
1555

1556
  private stripQualifiedKey(key: string): string {
1557
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1558
  }
1559

1560
  private validateConfiguration(): void {
1561
    if (
261✔
1562
      this.options.broadcastL1Invalidation !== undefined &&
266✔
1563
      this.options.publishSetInvalidation !== undefined &&
1564
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1565
    ) {
1566
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1567
    }
1568

1569
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
260✔
1570
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1571
    }
1572

1573
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
258✔
1574
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
258✔
1575
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
258✔
1576
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
258✔
1577
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
258✔
1578
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
258✔
1579
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
258✔
1580
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
258✔
1581
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
258✔
1582
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
258✔
1583
    if (this.options.snapshotMaxBytes !== false) {
258✔
1584
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
256✔
1585
    }
1586
    if (this.options.snapshotMaxEntries !== false) {
257✔
1587
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
256✔
1588
    }
1589
    if (this.options.invalidationMaxKeys !== false) {
257✔
1590
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
255✔
1591
    }
1592
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
257✔
1593
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
257✔
1594
    validateCircuitBreakerOptions(this.options.circuitBreaker)
257✔
1595
    if (typeof this.options.generationCleanup === 'object') {
257✔
1596
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
2✔
1597
    }
1598
    if (this.options.generation !== undefined) {
257✔
1599
      validateNonNegativeNumber('generation', this.options.generation)
6✔
1600
    }
1601
  }
1602

1603
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1604
    if (!options) {
490✔
1605
      return
319✔
1606
    }
1607

1608
    validateLayerNumberOption('options.ttl', options.ttl)
171✔
1609
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
171✔
1610
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
171✔
1611
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
171✔
1612
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
171✔
1613
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
171✔
1614
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
171✔
1615
    validateAdaptiveTtlOptions(options.adaptiveTtl)
171✔
1616
    validateCircuitBreakerOptions(options.circuitBreaker)
171✔
1617
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
171✔
1618
    validateTags(options.tags)
171✔
1619
    if (options.contextOptions && typeof options.contextOptions !== 'function') {
171✔
1620
      throw new Error('options.contextOptions must be a function.')
1✔
1621
    }
1622
  }
1623

1624
  private assertActive(operation: string): void {
1625
    if (this.isDisconnecting) {
1,166✔
1626
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1627
    }
1628
  }
1629

1630
  private async awaitStartup(operation: string): Promise<void> {
1631
    this.assertActive(operation)
566✔
1632
    await this.startup
566✔
1633
    this.assertActive(operation)
561✔
1634
  }
1635

1636
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1637
    return this.reader.readLayerEntry(layer, key)
39✔
1638
  }
1639

1640
  private scheduleBackgroundRefresh<T>(
1641
    key: string,
1642
    fetcher: CacheFetcher<T>,
1643
    options?: CacheGetOptions,
1644
    fetcherContext?: CacheFetcherContext<T>
1645
  ): void {
1646
    this.reader.runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext)
1✔
1647
  }
1648

1649
  private async applyFreshReadPolicies<T>(
1650
    key: string,
1651
    hit: {
1652
      found: true
1653
      value: T | null
1654
      stored: unknown
1655
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
1656
      layerIndex: number
1657
      layerName: string
1658
    },
1659
    options: CacheGetOptions | undefined,
1660
    fetcher?: CacheFetcher<T>
1661
  ): Promise<void> {
1662
    return this.reader.runApplyFreshReadPolicies(key, hit, options, fetcher)
2✔
1663
  }
1664

1665
  private shouldSkipLayer(layer: CacheLayer): boolean {
1666
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
865✔
1667
    const skip = shouldSkipDegradedLayer(degradedUntil)
865✔
1668
    if (!skip && degradedUntil !== undefined) {
865✔
1669
      this.layerDegradedUntil.delete(layer.name)
1✔
1670
    }
1671
    return skip
865✔
1672
  }
1673

1674
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1675
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
17✔
1676
    if (!recovery.degrade) {
17✔
1677
      throw error
4✔
1678
    }
1679

1680
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
13✔
1681
    this.metricsCollector.increment('degradedOperations')
13✔
1682
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
13✔
1683
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
17✔
1684
    return null
17✔
1685
  }
1686

1687
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1688
    if (this.isGracefulDegradationEnabled()) {
7✔
1689
      await this.handleLayerFailure(layer, operation, error)
5✔
1690
      return
5✔
1691
    }
1692

1693
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1694
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
7✔
1695
  }
1696

1697
  private isGracefulDegradationEnabled(): boolean {
1698
    return Boolean(this.options.gracefulDegradation)
9✔
1699
  }
1700

1701
  private recordCircuitFailure(
1702
    key: string,
1703
    breakerKey: string,
1704
    options: CacheCircuitBreakerOptions | undefined,
1705
    error: unknown
1706
  ): void {
1707
    if (!options) {
17✔
1708
      return
11✔
1709
    }
1710

1711
    this.circuitBreakerManager.recordFailure(breakerKey, options)
6✔
1712
    if (this.circuitBreakerManager.isOpen(breakerKey)) {
6!
1713
      this.metricsCollector.increment('circuitBreakerTrips')
6✔
1714
    }
1715
    this.emitError('fetch', { key, breakerKey, error: this.formatError(error) })
6✔
1716
  }
1717

1718
  private emitError(operation: string, context: Record<string, unknown>): void {
1719
    this.logger.error?.(operation, context)
26✔
1720
    if (this.listenerCount('error') > 0) {
26✔
1721
      this.emit('error', { operation, ...context })
9✔
1722
    }
1723
  }
1724

1725
  private snapshotMaxBytes(): number | false {
1726
    return this.options.snapshotMaxBytes === false
10✔
1727
      ? false
1728
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
17✔
1729
  }
1730

1731
  private snapshotMaxEntries(): number | false {
1732
    return this.options.snapshotMaxEntries === false
8✔
1733
      ? false
1734
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
13✔
1735
  }
1736

1737
  private invalidationMaxKeys(): number | false {
1738
    return this.options.invalidationMaxKeys === false
48✔
1739
      ? false
1740
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
86✔
1741
  }
1742
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc