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

flyingsquirrel0419 / layercache / 25602011727

09 May 2026 01:13PM UTC coverage: 95.636% (+0.002%) from 95.634%
25602011727

Pull #91

github

web-flow
Merge 770e0822d into 72606df0e
Pull Request #91: Merge cache API, invalidation, and concurrency updates

1785 of 1923 branches covered (92.82%)

Branch coverage included in aggregate %.

141 of 145 new or added lines in 8 files covered. (97.24%)

20 existing lines in 2 files now uncovered.

3212 of 3302 relevant lines covered (97.27%)

330.83 hits per line

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

94.1
/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
249✔
95
  }
96

97
  debug(message: string, context?: Record<string, unknown>): void {
98
    this.write('debug', message, context)
723✔
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) {
803✔
115
      return
801✔
116
    }
117

118
    const suffix = context ? ` ${JSON.stringify(context)}` : ''
2✔
119
    console[level](`[layercache] ${message}${suffix}`)
803✔
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()
261✔
150
  private readonly instanceId = createInstanceId()
261✔
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()
261✔
157
  private readonly snapshotSerializer = new JsonSerializer()
261✔
158
  private readonly invalidation: CacheStackInvalidationSupport
159
  private readonly layerWriter: CacheStackLayerWriter
160
  private readonly snapshots: CacheStackSnapshotManager
161
  private readonly layerDegradedUntil = new Map<string, number>()
261✔
162
  private readonly maintenance = new CacheStackMaintenance()
261✔
163
  private readonly ttlResolver: TtlResolver
164
  private readonly circuitBreakerManager: CircuitBreakerManager
165
  private nextOperationId = 0
261✔
166
  private currentGeneration?: number
167
  private isDisconnecting = false
261✔
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[],
261✔
176
    private readonly options: CacheStackOptions = {}
261✔
177
  ) {
178
    super()
261✔
179

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

184
    this.validateConfiguration()
260✔
185

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

195
    if (options.publishSetInvalidation !== undefined) {
261✔
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
256✔
202
    this.logger =
261✔
203
      typeof options.logger === 'object' ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv)
752✔
204
    this.tagIndex = options.tagIndex ?? new TagIndex()
261✔
205
    this.keyDiscovery = new CacheKeyDiscovery({
261✔
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({
261✔
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({
261✔
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)) {
286✔
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)) {
287✔
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 (
256✔
253
      options.invalidationBus &&
289✔
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({
256✔
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({
256✔
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),
481✔
284
      handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
5✔
285
      emit: (event, data) => this.emit(event, data as never),
425✔
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),
81✔
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)
256✔
317
    this.startup = this.initialize()
256✔
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
    const userKey = validateCacheKey(key)
5✔
349
    const normalizedKey = this.qualifyKey(userKey)
5✔
350
    await this.awaitStartup('getEntry')
5✔
351

352
    for (const layer of this.layers) {
5✔
353
      if (this.shouldSkipLayer(layer)) {
5!
NEW
354
        continue
×
355
      }
356

357
      const stored = await this.readLayerEntry(layer, normalizedKey)
5✔
358
      if (stored === null) {
5✔
359
        continue
2✔
360
      }
361

362
      const resolved = resolveStoredValue<T>(stored)
3✔
363
      if (resolved.state === 'expired') {
3!
NEW
364
        continue
×
365
      }
366

367
      return {
3✔
368
        key: userKey,
369
        value: resolved.value,
370
        kind: resolved.envelope?.kind ?? 'value',
3!
371
        state: resolved.state,
372
        layer: layer.name
373
      }
374
    }
375

376
    return null
2✔
377
  }
378

379
  /**
380
   * Like `get()`, but throws `CacheMissError` instead of returning `null`.
381
   * Useful when the value is expected to exist or the fetcher is expected to
382
   * return non-null.
383
   */
384
  async getOrThrow<T>(key: string, fetcher?: CacheFetcher<T>, options?: CacheGetOptions): Promise<T> {
385
    const value = await this.get(key, fetcher, options)
4✔
386
    if (value === null) {
4✔
387
      throw new CacheMissError(key)
3✔
388
    }
389
    return value
1✔
390
  }
391

392
  /**
393
   * Returns true if the given key exists and is not expired in any layer.
394
   */
395
  async has(key: string): Promise<boolean> {
396
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
9✔
397
    await this.awaitStartup('has')
9✔
398

399
    for (const layer of this.layers) {
9✔
400
      if (this.shouldSkipLayer(layer)) {
17!
401
        continue
×
402
      }
403
      if (layer.has) {
17✔
404
        try {
5✔
405
          const exists = await layer.has(normalizedKey)
5✔
406
          if (exists) {
4✔
407
            return true
2✔
408
          }
409
        } catch {
410
          await this.reportRecoverableLayerFailure(layer, 'has', new Error(`has() failed for layer "${layer.name}"`))
1✔
411
          // fall through to next layer
412
        }
413
      } else {
414
        try {
12✔
415
          const value = await layer.get(normalizedKey)
12✔
416
          if (value !== null) {
8✔
417
            return true
2✔
418
          }
419
        } catch (error) {
420
          await this.reportRecoverableLayerFailure(layer, 'has', error)
4✔
421
          // fall through
422
        }
423
      }
424
    }
425
    return false
5✔
426
  }
427

428
  /**
429
   * Returns the remaining TTL in milliseconds for the key in the fastest layer
430
   * that has it, or null if the key is not found / has no TTL.
431
   */
432
  async ttl(key: string): Promise<number | null> {
433
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
4✔
434
    await this.awaitStartup('ttl')
4✔
435

436
    for (const layer of this.layers) {
4✔
437
      if (this.shouldSkipLayer(layer)) {
8✔
438
        continue
1✔
439
      }
440
      if (layer.ttl) {
7✔
441
        try {
6✔
442
          const remaining = await layer.ttl(normalizedKey)
6✔
443
          if (remaining !== null) {
4✔
444
            return remaining
3✔
445
          }
446
        } catch {
447
          // fall through
448
        }
449
      }
450
    }
451
    return null
1✔
452
  }
453

454
  /**
455
   * Stores a value in all cache layers. Overwrites any existing value.
456
   */
457
  async set<T>(key: string, value: T, options?: CacheWriteOptions): Promise<void> {
458
    await this.observeOperation('layercache.set', { 'layercache.key': String(key ?? '') }, async () => {
127✔
459
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
127✔
460
      this.validateWriteOptions(options)
127✔
461
      await this.awaitStartup('set')
127✔
462
      await this.storeEntry(normalizedKey, 'value', value, options)
122✔
463
    })
464
  }
465

466
  /**
467
   * Deletes the key from all layers and publishes an invalidation message.
468
   */
469
  async delete(key: string): Promise<void> {
470
    await this.observeOperation('layercache.delete', { 'layercache.key': String(key ?? '') }, async () => {
9✔
471
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
9✔
472
      await this.awaitStartup('delete')
9✔
473
      await this.deleteKeys([normalizedKey])
8✔
474
      await this.publishInvalidation({
8✔
475
        scope: 'key',
476
        keys: [normalizedKey],
477
        sourceId: this.instanceId,
478
        operation: 'delete'
479
      })
480
    })
481
  }
482

483
  /**
484
   * Clears every configured layer, removes tag metadata, resets internal TTL
485
   * profiles, and broadcasts a clear invalidation message.
486
   */
487
  async clear(): Promise<void> {
488
    await this.awaitStartup('clear')
4✔
489
    this.maintenance.beginClearEpoch()
4✔
490
    await Promise.all(this.layers.map((layer) => layer.clear()))
5✔
491
    await this.tagIndex.clear()
4✔
492
    this.ttlResolver.clearProfiles()
4✔
493
    this.circuitBreakerManager.clear()
4✔
494
    this.metricsCollector.increment('invalidations')
4✔
495
    this.logger.debug?.('clear')
4✔
496
    await this.publishInvalidation({ scope: 'clear', sourceId: this.instanceId, operation: 'clear' })
4✔
497
  }
498

499
  /**
500
   * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
501
   */
502
  async mdelete(keys: string[]): Promise<void> {
503
    if (keys.length === 0) {
5✔
504
      return
1✔
505
    }
506
    await this.awaitStartup('mdelete')
4✔
507
    const normalizedKeys = keys.map((k) => validateCacheKey(k))
6✔
508
    const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key))
6✔
509
    await this.deleteKeys(cacheKeys)
4✔
510
    await this.publishInvalidation({
4✔
511
      scope: 'keys',
512
      keys: cacheKeys,
513
      sourceId: this.instanceId,
514
      operation: 'delete'
515
    })
516
  }
517

518
  /**
519
   * Alias for `delete(key)` that matches the `invalidateBy*` API family.
520
   */
521
  async invalidateByKey(key: string): Promise<void> {
522
    await this.delete(key)
2✔
523
  }
524

525
  /**
526
   * Alias for `mdelete(keys)` that matches the `invalidateBy*` API family.
527
   */
528
  async invalidateByKeys(keys: string[]): Promise<void> {
529
    await this.mdelete(keys)
2✔
530
  }
531

532
  /**
533
   * Marks one exact key expired without deleting its stale value.
534
   */
535
  async expireByKey(key: string): Promise<void> {
536
    await this.observeOperation('layercache.expire_by_key', { 'layercache.key': String(key ?? '') }, async () => {
2!
537
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
2✔
538
      await this.awaitStartup('expireByKey')
2✔
539
      await this.expireKeys([normalizedKey])
2✔
540
      await this.publishInvalidation({
2✔
541
        scope: 'key',
542
        keys: [normalizedKey],
543
        sourceId: this.instanceId,
544
        operation: 'expire'
545
      })
546
    })
547
  }
548

549
  /**
550
   * Marks multiple exact keys expired without deleting their stale values.
551
   */
552
  async expireByKeys(keys: string[]): Promise<void> {
553
    await this.observeOperation('layercache.expire_by_keys', undefined, async () => {
3✔
554
      if (keys.length === 0) {
3✔
555
        return
1✔
556
      }
557

558
      const normalizedKeys = keys.map((k) => validateCacheKey(k))
3✔
559
      const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key))
3✔
560
      await this.awaitStartup('expireByKeys')
2✔
561
      await this.expireKeys(cacheKeys)
2✔
562
      await this.publishInvalidation({
2✔
563
        scope: 'keys',
564
        keys: cacheKeys,
565
        sourceId: this.instanceId,
566
        operation: 'expire'
567
      })
568
    })
569
  }
570

571
  /**
572
   * Reads many keys concurrently. Simple reads use layer-level bulk fast paths;
573
   * entries with fetchers or options fall back to per-entry read-through logic.
574
   */
575
  async mget<T>(entries: CacheMGetEntry<T>[]): Promise<Array<T | null>> {
576
    return this.observeOperation('layercache.mget', undefined, async () => {
9✔
577
      this.assertActive('mget')
9✔
578
      if (entries.length === 0) {
9✔
579
        return []
1✔
580
      }
581

582
      const normalizedEntries = entries.map((entry) => ({
18✔
583
        ...entry,
584
        key: this.qualifyKey(validateCacheKey(entry.key))
585
      }))
586
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
18✔
587
      const canFastPath = normalizedEntries.every((entry) => entry.fetch === undefined && entry.options === undefined)
16✔
588
      if (!canFastPath) {
8✔
589
        await this.awaitStartup('mget')
2✔
590
        const pendingReads = new Map<
2✔
591
          string,
592
          {
593
            promise: Promise<T | null>
594
            fetch?: CacheFetcher<T>
595
            optionsSignature: string
596
          }
597
        >()
598

599
        return Promise.all(
2✔
600
          normalizedEntries.map((entry) => {
601
            const optionsSignature = serializeOptions(entry.options)
4✔
602
            const existing = pendingReads.get(entry.key)
4✔
603
            if (!existing) {
4✔
604
              const promise = this.reader.getPrepared(entry.key, entry.fetch, entry.options)
2✔
605
              pendingReads.set(entry.key, {
2✔
606
                promise,
607
                fetch: entry.fetch,
608
                optionsSignature
609
              })
610
              return promise
2✔
611
            }
612

613
            if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2!
614
              const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key
2!
615
              throw new Error(`mget received conflicting entries for key "${displayKey}".`)
2✔
616
            }
617

UNCOV
618
            return existing.promise
×
619
          })
620
        )
621
      }
622

623
      await this.awaitStartup('mget')
6✔
624
      const pending = new Set<string>()
6✔
625
      const indexesByKey = new Map<string, number[]>()
6✔
626
      const resultsByKey = new Map<string, T | null>()
6✔
627

628
      for (let index = 0; index < normalizedEntries.length; index += 1) {
6✔
629
        const entry = normalizedEntries[index]
14✔
630
        if (!entry) continue
14!
631
        const key = entry.key
14✔
632
        const indexes = indexesByKey.get(key) ?? []
14✔
633
        indexes.push(index)
14✔
634
        indexesByKey.set(key, indexes)
14✔
635
        pending.add(key)
14✔
636
      }
637

638
      for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
6✔
639
        const layer = this.layers[layerIndex]
6✔
640
        if (!layer || this.shouldSkipLayer(layer)) continue
6!
641
        const keys = [...pending]
6✔
642
        if (keys.length === 0) {
6!
UNCOV
643
          break
×
644
        }
645

646
        const values = layer.getMany
6!
647
          ? await layer.getMany(keys)
UNCOV
648
          : await Promise.all(keys.map((key) => this.reader.readLayerEntry(layer, key)))
×
649

UNCOV
650
        for (let offset = 0; offset < values.length; offset += 1) {
×
651
          const key = keys[offset]
13✔
652
          const stored = values[offset]
13✔
653
          if (!key || stored === null) {
13✔
654
            continue
2✔
655
          }
656

657
          const resolved = resolveStoredValue<T>(stored)
11✔
658
          if (resolved.state === 'expired') {
11✔
659
            await layer.delete(key)
1✔
660
            continue
1✔
661
          }
662

663
          if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
10!
UNCOV
664
            this.metricsCollector.increment('staleHits', indexesByKey.get(key)?.length ?? 1)
×
665
          }
666

667
          await this.tagIndex.touch(key)
10✔
668
          await this.reader.backfill(key, stored, layerIndex - 1)
10✔
669
          resultsByKey.set(key, resolved.value)
10✔
670
          pending.delete(key)
10✔
671
          this.metricsCollector.increment('hits', indexesByKey.get(key)?.length ?? 1)
10!
672
        }
673
      }
674

675
      if (pending.size > 0) {
6✔
676
        for (const key of pending) {
2✔
677
          await this.tagIndex.remove(key)
3✔
678
          this.metricsCollector.increment('misses', indexesByKey.get(key)?.length ?? 1)
3!
679
        }
680
      }
681

682
      return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null)
14✔
683
    })
684
  }
685

686
  /**
687
   * Writes many entries concurrently using each layer's bulk write fast path
688
   * when available.
689
   */
690
  async mset<T>(entries: CacheMSetEntry<T>[]): Promise<void> {
691
    await this.observeOperation('layercache.mset', undefined, async () => {
14✔
692
      this.assertActive('mset')
14✔
693
      const normalizedEntries = entries.map((entry) => ({
32✔
694
        ...entry,
695
        key: this.qualifyKey(validateCacheKey(entry.key))
696
      }))
697
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
32✔
698
      await this.awaitStartup('mset')
14✔
699
      await this.writeBatch(normalizedEntries)
14✔
700
    })
701
  }
702

703
  /**
704
   * Pre-populates cache entries by running their fetchers with bounded
705
   * concurrency. Higher-priority entries run first.
706
   */
707
  async warm(entries: CacheWarmEntry[], options: CacheWarmOptions = {}): Promise<void> {
4✔
708
    this.assertActive('warm')
4✔
709
    const concurrency = Math.max(1, options.concurrency ?? 4)
4✔
710
    const total = entries.length
4✔
711
    let completed = 0
4✔
712
    const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
4!
713
    const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
4!
714
      while (queue.length > 0) {
4✔
715
        const entry = queue.shift()
6✔
716
        if (!entry) {
6!
UNCOV
717
          return
×
718
        }
719

720
        let success = false
6✔
721
        try {
6✔
722
          await this.get(entry.key, entry.fetcher, entry.options)
6✔
723
          this.emit('warm', { key: entry.key })
4✔
724
          success = true
4✔
725
        } catch (error) {
726
          this.emitError('warm', { key: entry.key, error: this.formatError(error) })
2✔
727
          if (!options.continueOnError) {
2✔
728
            throw error
1✔
729
          }
730
        } finally {
731
          completed += 1
6✔
732
          const progress: CacheWarmProgress = { completed, total, key: entry.key, success }
6✔
733
          options.onProgress?.(progress)
6✔
734
        }
735
      }
736
    })
737

738
    await Promise.all(workers)
4✔
739
  }
740

741
  /**
742
   * Returns a cached version of `fetcher`. The cache key is derived from
743
   * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
744
   */
745
  wrap<TArgs extends unknown[], TResult>(
746
    prefix: string,
747
    fetcher: (...args: TArgs) => Promise<TResult>,
748
    options: CacheWrapOptions<TArgs> = {}
9✔
749
  ): (...args: TArgs) => Promise<TResult | null> {
750
    return (...args: TArgs) => {
9✔
751
      const suffix = options.keyResolver
16✔
752
        ? options.keyResolver(...args)
753
        : args.map((argument) => serializeKeyPart(argument)).join(':')
9✔
754
      const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix
16!
755
      return this.get<TResult>(key, () => fetcher(...args), options)
16✔
756
    }
757
  }
758

759
  /**
760
   * Creates a `CacheNamespace` that automatically prefixes all keys with
761
   * `prefix:`. Useful for multi-tenant or module-level isolation.
762
   */
763
  namespace(prefix: string): CacheNamespace {
764
    validateNamespaceKey(prefix)
47✔
765
    return new CacheNamespace(this, prefix)
47✔
766
  }
767

768
  /**
769
   * Deletes every key currently associated with `tag` and broadcasts an
770
   * invalidation message.
771
   */
772
  async invalidateByTag(tag: string): Promise<void> {
773
    await this.observeOperation('layercache.invalidate_by_tag', undefined, async () => {
8✔
774
      validateTag(tag)
8✔
775
      await this.awaitStartup('invalidateByTag')
8✔
776
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
7✔
777
      await this.deleteKeys(keys)
6✔
778
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
779
    })
780
  }
781

782
  /**
783
   * Marks every key associated with `tag` as expired while preserving stale
784
   * windows for stale serving.
785
   */
786
  async expireByTag(tag: string): Promise<void> {
787
    await this.observeOperation('layercache.expire_by_tag', undefined, async () => {
4✔
788
      validateTag(tag)
4✔
789
      await this.awaitStartup('expireByTag')
4✔
790
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
4✔
791
      await this.expireKeys(keys)
4✔
792
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
4✔
793
    })
794
  }
795

796
  /**
797
   * Deletes keys associated with any or all of the provided tags and broadcasts
798
   * an invalidation message.
799
   */
800
  async invalidateByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
5✔
801
    await this.observeOperation('layercache.invalidate_by_tags', undefined, async () => {
5✔
802
      if (tags.length === 0) {
5✔
803
        return
1✔
804
      }
805

806
      validateTags(tags)
4✔
807
      await this.awaitStartup('invalidateByTags')
4✔
808
      const keysByTag = await Promise.all(
4✔
809
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
7✔
810
      )
811
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
3✔
812
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
5✔
813

814
      await this.deleteKeys(keys)
5✔
815
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
816
    })
817
  }
818

819
  /**
820
   * Marks keys associated with any or all of the provided tags as expired while
821
   * preserving stale windows for stale serving.
822
   */
823
  async expireByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
3✔
824
    await this.observeOperation('layercache.expire_by_tags', undefined, async () => {
3✔
825
      if (tags.length === 0) {
3✔
826
        return
1✔
827
      }
828

829
      validateTags(tags)
2✔
830
      await this.awaitStartup('expireByTags')
2✔
831
      const keysByTag = await Promise.all(
2✔
832
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
4✔
833
      )
834
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
2!
835
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
3✔
836

837
      await this.expireKeys(keys)
3✔
838
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
839
    })
840
  }
841

842
  /**
843
   * Deletes keys matching a wildcard pattern such as `user:*`.
844
   */
845
  async invalidateByPattern(pattern: string): Promise<void> {
846
    await this.observeOperation('layercache.invalidate_by_pattern', undefined, async () => {
5✔
847
      validatePattern(pattern)
5✔
848
      await this.awaitStartup('invalidateByPattern')
5✔
849
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
5✔
850
        this.qualifyPattern(pattern),
851
        this.invalidationMaxKeys()
852
      )
853
      await this.deleteKeys(keys)
4✔
854
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
4✔
855
    })
856
  }
857

858
  /**
859
   * Marks keys matching a wildcard pattern as expired while preserving stale
860
   * windows for stale serving.
861
   */
862
  async expireByPattern(pattern: string): Promise<void> {
863
    await this.observeOperation('layercache.expire_by_pattern', undefined, async () => {
3✔
864
      validatePattern(pattern)
3✔
865
      await this.awaitStartup('expireByPattern')
3✔
866
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
3✔
867
        this.qualifyPattern(pattern),
868
        this.invalidationMaxKeys()
869
      )
870
      await this.expireKeys(keys)
3✔
871
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
3✔
872
    })
873
  }
874

875
  /**
876
   * Deletes keys that start with the provided prefix.
877
   */
878
  async invalidateByPrefix(prefix: string): Promise<void> {
879
    await this.observeOperation('layercache.invalidate_by_prefix', undefined, async () => {
7✔
880
      await this.awaitStartup('invalidateByPrefix')
7✔
881
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
7✔
882
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
7✔
883
      await this.deleteKeys(keys)
6✔
884
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
885
    })
886
  }
887

888
  /**
889
   * Marks keys that start with the provided prefix as expired while preserving
890
   * stale windows for stale serving.
891
   */
892
  async expireByPrefix(prefix: string): Promise<void> {
893
    await this.observeOperation('layercache.expire_by_prefix', undefined, async () => {
3✔
894
      await this.awaitStartup('expireByPrefix')
3✔
895
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
3✔
896
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
3✔
897
      await this.expireKeys(keys)
2✔
898
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
899
    })
900
  }
901

902
  /**
903
   * Returns cumulative cache metrics since startup or the last `resetMetrics()`.
904
   */
905
  getMetrics(): CacheMetricsSnapshot {
906
    return this.metricsCollector.snapshot
53✔
907
  }
908

909
  /**
910
   * Runs an operation while collecting only the metrics emitted by its async context.
911
   * Used by namespaces so metrics tracking does not serialize the operation itself.
912
   */
913
  async captureMetrics<T>(operation: () => Promise<T>): Promise<{ result: T; metrics: CacheMetricsSnapshot }> {
914
    return this.metricsCollector.capture(operation)
101✔
915
  }
916

917
  /**
918
   * Returns metrics plus layer degradation state and active background refresh count.
919
   */
920
  getStats(): CacheStatsSnapshot {
921
    return {
28✔
922
      metrics: this.getMetrics(),
923
      layers: this.layers.map((layer) => ({
32✔
924
        name: layer.name,
925
        isLocal: Boolean(layer.isLocal),
926
        degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
60✔
927
      })),
928
      backgroundRefreshes: this.reader.activeRefreshCount
929
    }
930
  }
931

932
  /**
933
   * Resets cumulative metrics counters.
934
   */
935
  resetMetrics(): void {
936
    this.metricsCollector.reset()
1✔
937
  }
938

939
  /**
940
   * Returns computed hit-rate statistics (overall and per-layer).
941
   */
942
  getHitRate(): CacheHitRateSnapshot {
943
    return this.metricsCollector.hitRate()
6✔
944
  }
945

946
  /**
947
   * Runs each layer's `ping()` hook when available and returns per-layer health
948
   * and latency information.
949
   */
950
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
951
    await this.startup
2✔
952

953
    return Promise.all(
2✔
954
      this.layers.map(async (layer) => {
955
        const startedAt = performance.now()
4✔
956
        try {
4✔
957
          const healthy = layer.ping ? await layer.ping() : true
4✔
958
          return {
4✔
959
            layer: layer.name,
960
            healthy,
961
            latencyMs: performance.now() - startedAt
962
          }
963
        } catch (error) {
964
          return {
1✔
965
            layer: layer.name,
966
            healthy: false,
967
            latencyMs: performance.now() - startedAt,
968
            error: this.formatError(error)
969
          }
970
        }
971
      })
972
    )
973
  }
974

975
  /**
976
   * Rotates the active generation prefix used for all future cache keys.
977
   * Previous-generation keys remain in the underlying layers until they expire,
978
   * unless `generationCleanup` is enabled to prune them in the background.
979
   */
980
  bumpGeneration(nextGeneration?: number): number {
981
    const current = this.currentGeneration ?? 0
5!
982
    const previousGeneration = this.currentGeneration
5✔
983
    const updatedGeneration = nextGeneration ?? current + 1
5✔
984
    const generationToCleanup = resolveGenerationCleanupTarget({
5✔
985
      previousGeneration,
986
      nextGeneration: updatedGeneration,
987
      generationCleanup: this.options.generationCleanup
988
    })
989

990
    this.currentGeneration = updatedGeneration
5✔
991
    if (generationToCleanup !== null) {
5✔
992
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
993
    }
994

995
    return this.currentGeneration
5✔
996
  }
997

998
  /**
999
   * Returns the active generation prefix number used for future cache keys.
1000
   */
1001
  getGeneration(): number | undefined {
1002
    return this.currentGeneration
3✔
1003
  }
1004

1005
  /**
1006
   * Returns detailed metadata about a single cache key: which layers contain it,
1007
   * remaining fresh/stale/error TTLs, and associated tags.
1008
   * Returns `null` if the key does not exist in any layer.
1009
   */
1010
  async inspect(key: string): Promise<CacheInspectResult | null> {
1011
    const userKey = validateCacheKey(key)
30✔
1012
    const normalizedKey = this.qualifyKey(userKey)
30✔
1013
    await this.awaitStartup('inspect')
30✔
1014

1015
    const foundInLayers: string[] = []
30✔
1016
    let freshTtlMs: number | null = null
30✔
1017
    let staleTtlMs: number | null = null
30✔
1018
    let errorTtlMs: number | null = null
30✔
1019
    let isStale = false
30✔
1020

1021
    for (const layer of this.layers) {
30✔
1022
      if (this.shouldSkipLayer(layer)) {
31!
UNCOV
1023
        continue
×
1024
      }
1025
      const stored = await this.readLayerEntry(layer, normalizedKey)
31✔
1026
      if (stored === null) {
31✔
1027
        continue
1✔
1028
      }
1029

1030
      const resolved = resolveStoredValue(stored)
30✔
1031
      if (resolved.state === 'expired') {
30!
UNCOV
1032
        continue
×
1033
      }
1034

1035
      foundInLayers.push(layer.name)
30✔
1036

1037
      // Take TTL info from the first (fastest) layer that has it
1038
      if (foundInLayers.length === 1 && resolved.envelope) {
30✔
1039
        const now = Date.now()
29✔
1040
        freshTtlMs =
29✔
1041
          resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.freshUntil - now)) : null
29!
1042
        staleTtlMs =
29✔
1043
          resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.staleUntil - now)) : null
29✔
1044
        errorTtlMs =
29✔
1045
          resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil(resolved.envelope.errorUntil - now)) : null
29✔
1046
        isStale = resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error'
29✔
1047
      }
1048
    }
1049

1050
    if (foundInLayers.length === 0) {
30✔
1051
      return null
1✔
1052
    }
1053

1054
    const tags = await this.getTagsForKey(normalizedKey)
29✔
1055

1056
    return { key: userKey, foundInLayers, freshTtlMs, staleTtlMs, errorTtlMs, isStale, tags }
29✔
1057
  }
1058

1059
  /**
1060
   * Exports cache entries from configured layers for process-local snapshots.
1061
   */
1062
  async exportState(): Promise<CacheSnapshotEntry[]> {
1063
    await this.awaitStartup('exportState')
2✔
1064
    return this.snapshots.exportState(this.snapshotMaxEntries())
2✔
1065
  }
1066

1067
  /**
1068
   * Imports entries produced by `exportState()` into the configured layers.
1069
   */
1070
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
1071
    await this.awaitStartup('importState')
1✔
1072
    await this.snapshots.importState(entries)
1✔
1073
  }
1074

1075
  /**
1076
   * Writes a snapshot file containing current cache entries.
1077
   */
1078
  async persistToFile(filePath: string): Promise<void> {
1079
    this.assertActive('persistToFile')
4✔
1080
    await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries())
4✔
1081
  }
1082

1083
  /**
1084
   * Restores cache entries from a snapshot file.
1085
   */
1086
  async restoreFromFile(filePath: string): Promise<void> {
1087
    this.assertActive('restoreFromFile')
8✔
1088
    await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes())
8✔
1089
  }
1090

1091
  /**
1092
   * Flushes background work, unsubscribes from buses, disposes timers, and then
1093
   * disposes each layer that provides `dispose()`.
1094
   */
1095
  async disconnect(): Promise<void> {
1096
    if (!this.disconnectPromise) {
28!
1097
      this.isDisconnecting = true
28✔
1098
      this.disconnectPromise = (async () => {
28✔
1099
        await this.startup
28✔
1100
        await this.unsubscribeInvalidation?.()
28✔
1101
        await this.flushWriteBehindQueue()
28✔
1102
        await this.maintenance.waitForGenerationCleanup()
28✔
1103
        this.reader.abortAllRefreshes()
28✔
1104
        await Promise.allSettled(
28✔
1105
          this.reader.getAllRefreshPromises().map((promise) => {
1106
            let timer: ReturnType<typeof setTimeout> | undefined
UNCOV
1107
            return Promise.race([
×
1108
              promise,
1109
              new Promise<void>((resolve) => {
UNCOV
1110
                timer = setTimeout(resolve, 5_000)
×
UNCOV
1111
                timer.unref?.()
×
1112
              })
1113
            ]).finally(() => {
UNCOV
1114
              if (timer) clearTimeout(timer)
×
1115
            })
1116
          })
1117
        )
1118
        this.maintenance.disposeWriteBehindTimer()
28✔
1119
        this.fetchRateLimiter.dispose()
28✔
1120
        await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()))
42✔
1121
      })()
1122
    }
1123

1124
    await this.disconnectPromise
28✔
1125
  }
1126

1127
  private async initialize(): Promise<void> {
1128
    if (!this.options.invalidationBus) {
256✔
1129
      return
238✔
1130
    }
1131

1132
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
18✔
1133
      await this.handleInvalidationMessage(message)
12✔
1134
    })
1135
  }
1136

1137
  private async storeEntry(
1138
    key: string,
1139
    kind: CacheWriteKind,
1140
    value: unknown,
1141
    options?: CacheWriteOptions
1142
  ): Promise<void> {
1143
    const resolvedOptions = this.resolveContextOptions(key, kind, value, options)
180✔
1144
    const clearEpoch = this.maintenance.currentClearEpoch()
180✔
1145
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
180✔
1146
    await this.layerWriter.writeAcrossLayers(key, kind, value, resolvedOptions)
180✔
1147
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
175!
UNCOV
1148
      return
×
1149
    }
1150
    if (resolvedOptions?.tags) {
175✔
1151
      await this.tagIndex.track(key, resolvedOptions.tags)
29✔
1152
    } else {
1153
      await this.tagIndex.touch(key)
146✔
1154
    }
1155

1156
    this.metricsCollector.increment('sets')
175✔
1157
    this.logger.debug?.('set', { key, kind, tags: resolvedOptions?.tags })
175✔
1158
    this.emit('set', { key, kind: kind as string, tags: resolvedOptions?.tags })
180✔
1159
    if (this.shouldBroadcastL1Invalidation()) {
180✔
1160
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
1161
    }
1162
  }
1163

1164
  private async writeBatch(
1165
    entries: Array<{ key: string; value: unknown; options?: CacheWriteOptions }>
1166
  ): Promise<void> {
1167
    const resolvedEntries = entries.map((entry) => ({
32✔
1168
      ...entry,
1169
      options: this.resolveContextOptions(entry.key, 'value', entry.value, entry.options)
1170
    }))
1171
    const { clearEpoch, entryEpochs } = await this.layerWriter.writeBatch(resolvedEntries)
14✔
1172
    if (clearEpoch !== this.maintenance.currentClearEpoch()) {
13!
UNCOV
1173
      return
×
1174
    }
1175

1176
    for (const entry of resolvedEntries) {
13✔
1177
      if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
30!
UNCOV
1178
        continue
×
1179
      }
1180
      if (entry.options?.tags) {
30✔
1181
        await this.tagIndex.track(entry.key, entry.options.tags)
2✔
1182
      } else {
1183
        await this.tagIndex.touch(entry.key)
28✔
1184
      }
1185

1186
      this.metricsCollector.increment('sets')
30✔
1187
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
30✔
1188
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
30✔
1189
    }
1190

1191
    if (this.shouldBroadcastL1Invalidation()) {
13✔
1192
      await this.publishInvalidation({
1✔
1193
        scope: 'keys',
1194
        keys: entries.map((entry) => entry.key),
2✔
1195
        sourceId: this.instanceId,
1196
        operation: 'write'
1197
      })
1198
    }
1199
  }
1200

1201
  private resolveFreshTtl(
1202
    key: string,
1203
    layerName: string,
1204
    kind: CacheWriteKind,
1205
    options: CacheWriteOptions | undefined,
1206
    fallbackTtl: number | undefined,
1207
    value: unknown
1208
  ): number | undefined {
1209
    return this.ttlResolver.resolveFreshTtl(
228✔
1210
      key,
1211
      layerName,
1212
      kind,
1213
      options,
1214
      fallbackTtl,
1215
      this.options.negativeTtl,
1216
      undefined,
1217
      value
1218
    )
1219
  }
1220

1221
  private resolveLayerMs(
1222
    layerName: string,
1223
    override: number | LayerTtlMap | undefined,
1224
    globalDefault?: number | LayerTtlMap,
1225
    fallback?: number
1226
  ): number | undefined {
1227
    return this.ttlResolver.resolveLayerMs(layerName, override, globalDefault, fallback)
537✔
1228
  }
1229

1230
  private resolveContextOptions(
1231
    key: string,
1232
    kind: CacheEntryWriteKind,
1233
    value: unknown,
1234
    options: CacheWriteOptions | undefined
1235
  ): CacheWriteOptions | undefined {
1236
    if (!options?.contextOptions) {
212✔
1237
      return options
204✔
1238
    }
1239

1240
    const { contextOptions, ...baseOptions } = options
8✔
1241
    let overrides: CacheEntryWriteOptions | undefined
1242
    try {
8✔
1243
      overrides = contextOptions({ key, value, kind } as CacheContextOptionsContext)
8✔
1244
    } catch (error) {
1245
      throw new Error(`options.contextOptions() failed for key "${key}": ${this.formatError(error)}`)
1✔
1246
    }
1247
    if (!overrides) {
7✔
1248
      return baseOptions
1✔
1249
    }
1250
    if (!this.isPlainObject(overrides)) {
6✔
1251
      throw new Error(
3✔
1252
        `options.contextOptions() must return a plain object or undefined for key "${key}". Async resolvers are not supported.`
1253
      )
1254
    }
1255

1256
    try {
3✔
1257
      validateContextEntryOptions('options.contextOptions()', overrides)
3✔
1258
    } catch (error) {
1259
      throw new Error(
1✔
1260
        `options.contextOptions() returned invalid entry options for key "${key}": ${this.formatError(error)}`
1261
      )
1262
    }
1263
    return {
2✔
1264
      ...baseOptions,
1265
      ...overrides
1266
    }
1267
  }
1268

1269
  private isPlainObject(value: unknown): value is Record<string, unknown> {
1270
    if (!value || typeof value !== 'object' || Array.isArray(value)) {
6✔
1271
      return false
2✔
1272
    }
1273

1274
    const prototype = Object.getPrototypeOf(value)
4✔
1275
    return prototype === Object.prototype || prototype === null
4✔
1276
  }
1277

1278
  private async deleteKeys(keys: string[]): Promise<void> {
1279
    if (keys.length === 0) {
32✔
1280
      return
4✔
1281
    }
1282

1283
    this.maintenance.bumpKeyEpochs(keys)
28✔
1284
    await this.invalidation.deleteKeysFromLayers(this.layers, keys)
28✔
1285

1286
    for (const key of keys) {
28✔
1287
      await this.tagIndex.remove(key)
36✔
1288
      this.ttlResolver.deleteProfile(key)
36✔
1289
      this.circuitBreakerManager.delete(`key:${key}`)
36✔
1290
    }
1291

1292
    this.metricsCollector.increment('deletes', keys.length)
28✔
1293
    this.metricsCollector.increment('invalidations')
28✔
1294
    this.logger.debug?.('delete', { keys })
28✔
1295
    this.emit('delete', { keys })
32✔
1296
  }
1297

1298
  private async expireKeys(keys: string[]): Promise<void> {
1299
    if (keys.length === 0) {
15✔
1300
      return
1✔
1301
    }
1302

1303
    this.maintenance.bumpKeyEpochs(keys)
14✔
1304
    const foundKeys = await this.expireKeysInLayers(keys, this.layers)
14✔
1305

1306
    for (const key of keys) {
14✔
1307
      if (foundKeys.has(key)) {
18✔
1308
        continue
17✔
1309
      }
1310

1311
      await this.tagIndex.remove(key)
1✔
1312
      this.ttlResolver.deleteProfile(key)
1✔
1313
      this.circuitBreakerManager.delete(`key:${key}`)
1✔
1314
    }
1315

1316
    this.metricsCollector.increment('invalidations')
14✔
1317
    this.logger.debug?.('expire', { keys })
14✔
1318
    this.emit('expire', { keys })
15✔
1319
  }
1320

1321
  private async expireKeysInLayers(keys: string[], layers: CacheLayer[]): Promise<Set<string>> {
1322
    if (keys.length === 0) {
16✔
1323
      return new Set()
1✔
1324
    }
1325

1326
    return this.invalidation.expireKeysInLayers(layers, keys)
15✔
1327
  }
1328

1329
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1330
    if (!this.options.invalidationBus) {
54✔
1331
      return
47✔
1332
    }
1333

1334
    await this.options.invalidationBus.publish(message)
7✔
1335
  }
1336

1337
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1338
    if (message.sourceId === this.instanceId) {
15✔
1339
      return
7✔
1340
    }
1341

1342
    const localLayers = this.layers.filter((layer) => layer.isLocal)
12✔
1343
    if (message.scope === 'clear') {
8✔
1344
      this.maintenance.beginClearEpoch()
2✔
1345
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1346
      await this.tagIndex.clear()
2✔
1347
      this.ttlResolver.clearProfiles()
2✔
1348
      this.circuitBreakerManager.clear()
2✔
1349
      return
2✔
1350
    }
1351

1352
    const keys = message.keys ?? []
6!
1353
    this.maintenance.bumpKeyEpochs(keys)
15✔
1354
    if (message.operation === 'expire') {
15✔
1355
      await this.expireKeysInLayers(keys, localLayers)
1✔
1356
      return
1✔
1357
    }
1358

1359
    await this.invalidation.deleteKeysFromLayers(localLayers, keys)
5✔
1360

1361
    if (message.operation !== 'write') {
5✔
1362
      for (const key of keys) {
2✔
1363
        await this.tagIndex.remove(key)
3✔
1364
        this.ttlResolver.deleteProfile(key)
3✔
1365
        this.circuitBreakerManager.delete(`key:${key}`)
3✔
1366
      }
1367
    }
1368
  }
1369

1370
  private async getTagsForKey(key: string): Promise<string[]> {
1371
    if (this.tagIndex.tagsForKey) {
31✔
1372
      return this.tagIndex.tagsForKey(key)
30✔
1373
    }
1374
    return []
1✔
1375
  }
1376

1377
  private formatError(error: unknown): string {
1378
    if (error instanceof Error) {
55✔
1379
      return error.message
54✔
1380
    }
1381

1382
    return String(error)
1✔
1383
  }
1384

1385
  private sleep(ms: number): Promise<void> {
1386
    return new Promise((resolve) => setTimeout(resolve, ms))
4✔
1387
  }
1388

1389
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1390
    if (timeoutMs <= 0) {
15✔
1391
      return promise
1✔
1392
    }
1393

1394
    let timer: ReturnType<typeof setTimeout> | undefined
1395
    const observedPromise = promise.then(
14✔
1396
      (value) => ({ kind: 'value' as const, value }),
9✔
1397
      (error) => ({ kind: 'error' as const, error })
2✔
1398
    )
1399
    try {
14✔
1400
      const result = await Promise.race([
14✔
1401
        observedPromise,
1402
        new Promise<T>((_, reject) => {
1403
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
14✔
1404
          timer.unref?.()
14✔
1405
        })
1406
      ])
1407
      if (result !== null && result !== undefined && typeof result === 'object' && 'kind' in result) {
10!
1408
        if (result.kind === 'error') {
10✔
1409
          throw result.error
1✔
1410
        }
1411
        return result.value
9✔
1412
      }
UNCOV
1413
      return result
×
1414
    } finally {
1415
      if (timer) {
14!
1416
        clearTimeout(timer)
14✔
1417
      }
1418
    }
1419
  }
1420

1421
  private shouldBroadcastL1Invalidation(): boolean {
1422
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
188✔
1423
  }
1424

1425
  private async observeOperation<T>(
1426
    name: string,
1427
    attributes: Record<string, unknown> | undefined,
1428
    execute: () => Promise<T>
1429
  ): Promise<T> {
1430
    const id = this.nextOperationId
518✔
1431
    this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER
518✔
1432
    this.emit('operation-start', { id, name, attributes })
518✔
1433

1434
    try {
518✔
1435
      const result = await execute()
518✔
1436
      this.emit('operation-end', {
476✔
1437
        id,
1438
        name,
1439
        attributes,
1440
        success: true,
1441
        result: result === null ? 'null' : undefined
476✔
1442
      })
1443
      return result
518✔
1444
    } catch (error) {
1445
      this.emit('operation-end', {
42✔
1446
        id,
1447
        name,
1448
        attributes,
1449
        success: false,
1450
        error
1451
      })
1452
      throw error
42✔
1453
    }
1454
  }
1455

1456
  private scheduleGenerationCleanup(generation: number): void {
1457
    this.maintenance.scheduleGenerationCleanup(
2✔
1458
      generation,
1459
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1460
      (failedGeneration, error) => {
1461
        this.logger.warn?.('generation-cleanup-error', {
1✔
1462
          generation: failedGeneration,
1463
          error: this.formatError(error)
1464
        })
1465
      }
1466
    )
1467
  }
1468

1469
  private async cleanupGeneration(generation: number): Promise<void> {
1470
    const prefix = `v${generation}:`
3✔
1471
    const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix)
3✔
1472
    for (const batch of planGenerationCleanupBatches(keys, this.options.generationCleanup)) {
2✔
1473
      await this.deleteKeys(batch)
1✔
1474
      await this.publishInvalidation({
1✔
1475
        scope: 'keys',
1476
        keys: batch,
1477
        sourceId: this.instanceId,
1478
        operation: 'invalidate'
1479
      })
1480
    }
1481
  }
1482

1483
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1484
    this.maintenance.initializeWriteBehindTimer(
256✔
1485
      this.options.writeStrategy,
1486
      options,
1487
      this.flushWriteBehindQueue.bind(this)
1488
    )
1489
  }
1490

1491
  private shouldWriteBehind(layer: CacheLayer): boolean {
1492
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
211✔
1493
  }
1494

1495
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1496
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1497
  }
1498

1499
  private async flushWriteBehindQueue(): Promise<void> {
1500
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
28✔
1501
  }
1502

1503
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1504
    const results = await Promise.allSettled(batch.map((operation) => operation()))
4✔
1505
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4✔
1506
    if (failures.length === 0) {
3✔
1507
      return
2✔
1508
    }
1509

1510
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1511
    this.logger.error?.('write-behind-flush-failure', {
1✔
1512
      failed: failures.length,
1513
      total: batch.length,
1514
      errors: failures.map((failure) => this.formatError(failure.reason))
1✔
1515
    })
1516
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1517
  }
1518

1519
  private qualifyKey(key: string): string {
1520
    return qualifyGenerationKey(key, this.currentGeneration)
570✔
1521
  }
1522

1523
  private qualifyPattern(pattern: string): string {
1524
    return qualifyGenerationPattern(pattern, this.currentGeneration)
8✔
1525
  }
1526

1527
  private stripQualifiedKey(key: string): string {
1528
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1529
  }
1530

1531
  private validateConfiguration(): void {
1532
    if (
260✔
1533
      this.options.broadcastL1Invalidation !== undefined &&
265✔
1534
      this.options.publishSetInvalidation !== undefined &&
1535
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1536
    ) {
1537
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1538
    }
1539

1540
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
259✔
1541
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1542
    }
1543

1544
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
257✔
1545
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
257✔
1546
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
257✔
1547
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
257✔
1548
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
257✔
1549
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
257✔
1550
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
257✔
1551
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
257✔
1552
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
257✔
1553
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
257✔
1554
    if (this.options.snapshotMaxBytes !== false) {
257✔
1555
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
255✔
1556
    }
1557
    if (this.options.snapshotMaxEntries !== false) {
256✔
1558
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
255✔
1559
    }
1560
    if (this.options.invalidationMaxKeys !== false) {
256✔
1561
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
254✔
1562
    }
1563
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
256✔
1564
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
256✔
1565
    validateCircuitBreakerOptions(this.options.circuitBreaker)
256✔
1566
    if (typeof this.options.generationCleanup === 'object') {
256✔
1567
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
2✔
1568
    }
1569
    if (this.options.generation !== undefined) {
256✔
1570
      validateNonNegativeNumber('generation', this.options.generation)
6✔
1571
    }
1572
  }
1573

1574
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1575
    if (!options) {
490✔
1576
      return
319✔
1577
    }
1578

1579
    validateLayerNumberOption('options.ttl', options.ttl)
171✔
1580
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
171✔
1581
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
171✔
1582
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
171✔
1583
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
171✔
1584
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
171✔
1585
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
171✔
1586
    validateAdaptiveTtlOptions(options.adaptiveTtl)
171✔
1587
    validateCircuitBreakerOptions(options.circuitBreaker)
171✔
1588
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
171✔
1589
    validateTags(options.tags)
171✔
1590
    if (options.contextOptions && typeof options.contextOptions !== 'function') {
171✔
1591
      throw new Error('options.contextOptions must be a function.')
1✔
1592
    }
1593
  }
1594

1595
  private assertActive(operation: string): void {
1596
    if (this.isDisconnecting) {
1,164✔
1597
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1598
    }
1599
  }
1600

1601
  private async awaitStartup(operation: string): Promise<void> {
1602
    this.assertActive(operation)
565✔
1603
    await this.startup
565✔
1604
    this.assertActive(operation)
560✔
1605
  }
1606

1607
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1608
    return this.reader.readLayerEntry(layer, key)
37✔
1609
  }
1610

1611
  private scheduleBackgroundRefresh<T>(
1612
    key: string,
1613
    fetcher: CacheFetcher<T>,
1614
    options?: CacheGetOptions,
1615
    fetcherContext?: CacheFetcherContext<T>
1616
  ): void {
1617
    this.reader.runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext)
1✔
1618
  }
1619

1620
  private async applyFreshReadPolicies<T>(
1621
    key: string,
1622
    hit: {
1623
      found: true
1624
      value: T | null
1625
      stored: unknown
1626
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
1627
      layerIndex: number
1628
      layerName: string
1629
    },
1630
    options: CacheGetOptions | undefined,
1631
    fetcher?: CacheFetcher<T>
1632
  ): Promise<void> {
1633
    return this.reader.runApplyFreshReadPolicies(key, hit, options, fetcher)
2✔
1634
  }
1635

1636
  private shouldSkipLayer(layer: CacheLayer): boolean {
1637
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
860✔
1638
    const skip = shouldSkipDegradedLayer(degradedUntil)
860✔
1639
    if (!skip && degradedUntil !== undefined) {
860✔
1640
      this.layerDegradedUntil.delete(layer.name)
1✔
1641
    }
1642
    return skip
860✔
1643
  }
1644

1645
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1646
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
17✔
1647
    if (!recovery.degrade) {
17✔
1648
      throw error
4✔
1649
    }
1650

1651
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
13✔
1652
    this.metricsCollector.increment('degradedOperations')
13✔
1653
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
13✔
1654
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
17✔
1655
    return null
17✔
1656
  }
1657

1658
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1659
    if (this.isGracefulDegradationEnabled()) {
7✔
1660
      await this.handleLayerFailure(layer, operation, error)
5✔
1661
      return
5✔
1662
    }
1663

1664
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1665
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
7✔
1666
  }
1667

1668
  private isGracefulDegradationEnabled(): boolean {
1669
    return Boolean(this.options.gracefulDegradation)
9✔
1670
  }
1671

1672
  private recordCircuitFailure(
1673
    key: string,
1674
    breakerKey: string,
1675
    options: CacheCircuitBreakerOptions | undefined,
1676
    error: unknown
1677
  ): void {
1678
    if (!options) {
17✔
1679
      return
11✔
1680
    }
1681

1682
    this.circuitBreakerManager.recordFailure(breakerKey, options)
6✔
1683
    if (this.circuitBreakerManager.isOpen(breakerKey)) {
6!
1684
      this.metricsCollector.increment('circuitBreakerTrips')
6✔
1685
    }
1686
    this.emitError('fetch', { key, breakerKey, error: this.formatError(error) })
6✔
1687
  }
1688

1689
  private emitError(operation: string, context: Record<string, unknown>): void {
1690
    this.logger.error?.(operation, context)
26✔
1691
    if (this.listenerCount('error') > 0) {
26✔
1692
      this.emit('error', { operation, ...context })
9✔
1693
    }
1694
  }
1695

1696
  private snapshotMaxBytes(): number | false {
1697
    return this.options.snapshotMaxBytes === false
10✔
1698
      ? false
1699
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
17✔
1700
  }
1701

1702
  private snapshotMaxEntries(): number | false {
1703
    return this.options.snapshotMaxEntries === false
8✔
1704
      ? false
1705
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
13✔
1706
  }
1707

1708
  private invalidationMaxKeys(): number | false {
1709
    return this.options.invalidationMaxKeys === false
48✔
1710
      ? false
1711
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
86✔
1712
  }
1713
}
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