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

flyingsquirrel0419 / layercache / 24937388270

25 Apr 2026 06:14PM UTC coverage: 95.04% (-0.3%) from 95.325%
24937388270

Pull #19

github

web-flow
Merge a6060d629 into 5999d48b9
Pull Request #19: Security/vulnerability fixes

1594 of 1722 branches covered (92.57%)

Branch coverage included in aggregate %.

28 of 39 new or added lines in 7 files covered. (71.79%)

2851 of 2955 relevant lines covered (96.48%)

262.64 hits per line

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

93.37
/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 {
22
  planFreshReadPolicies,
23
  resolveRecoverableLayerFailure,
24
  shouldSkipLayer as shouldSkipDegradedLayer,
25
  shouldStartBackgroundRefresh
26
} from './internal/CacheStackRuntimePolicy'
27
import { CacheStackSnapshotManager } from './internal/CacheStackSnapshotManager'
28
import {
29
  validateAdaptiveTtlOptions,
30
  validateCacheKey,
31
  validateCircuitBreakerOptions,
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 { isStoredValueEnvelope, remainingStoredTtlSeconds, 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 CacheGetOptions,
53
  type CacheHealthCheckResult,
54
  type CacheHitRateSnapshot,
55
  type CacheInspectResult,
56
  type CacheLayer,
57
  type CacheLayerSetManyEntry,
58
  type CacheLogger,
59
  type CacheMGetEntry,
60
  type CacheMSetEntry,
61
  type CacheMetricsSnapshot,
62
  CacheMissError,
63
  type CacheSingleFlightExecutionOptions,
64
  type CacheSnapshotEntry,
65
  type CacheStackEvents,
66
  type CacheStackOptions,
67
  type CacheStatsSnapshot,
68
  type CacheTagIndex,
69
  type CacheTtlPolicy,
70
  type CacheWarmEntry,
71
  type CacheWarmOptions,
72
  type CacheWarmProgress,
73
  type CacheWrapOptions,
74
  type CacheWriteBehindOptions,
75
  type CacheWriteOptions,
76
  type InvalidationMessage,
77
  type LayerTtlMap
78
} from './types'
79

80
const DEFAULT_SINGLE_FLIGHT_LEASE_MS = 30_000
11✔
81
const DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5_000
11✔
82
const DEFAULT_SINGLE_FLIGHT_POLL_MS = 50
11✔
83
const DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 30_000
11✔
84
const DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1_024 * 1_024
11✔
85
const DEFAULT_SNAPSHOT_MAX_ENTRIES = 10_000
11✔
86
const DEFAULT_INVALIDATION_MAX_KEYS = 10_000
11✔
87
const DEFAULT_MAX_PROFILE_ENTRIES = 100_000
11✔
88

89
type ReadMode = 'allow-stale' | 'fresh-only'
90

91
type ReadHit<T> =
92
  | {
93
      found: true
94
      value: T | null
95
      stored: unknown
96
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
97
      layerIndex: number
98
      layerName: string
99
    }
100
  | { found: false; value: null; stored: null; state: 'miss' }
101

102
class DebugLogger implements CacheLogger {
103
  private readonly enabled: boolean
104

105
  constructor(enabled: boolean) {
106
    this.enabled = enabled
211✔
107
  }
108

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

113
  info(message: string, context?: Record<string, unknown>): void {
114
    this.write('info', message, context)
3✔
115
  }
116

117
  warn(message: string, context?: Record<string, unknown>): void {
118
    this.write('warn', message, context)
46✔
119
  }
120

121
  error(message: string, context?: Record<string, unknown>): void {
122
    this.write('error', message, context)
19✔
123
  }
124

125
  private write(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
126
    if (!this.enabled) {
653✔
127
      return
651✔
128
    }
129

130
    const suffix = context ? ` ${JSON.stringify(context)}` : ''
2✔
131
    console[level](`[layercache] ${message}${suffix}`)
653✔
132
  }
133
}
134

135
/** Typed overloads for EventEmitter so callers get autocomplete on event names. */
136
export interface CacheStack {
137
  on<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
138
  once<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
139
  off<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
140
  removeAllListeners<K extends keyof CacheStackEvents>(event?: K): this
141
  listeners<K extends keyof CacheStackEvents>(event: K): Array<(data: CacheStackEvents[K]) => void>
142
  listenerCount<K extends keyof CacheStackEvents>(event: K): number
143
  emit<K extends keyof CacheStackEvents>(event: K, data: CacheStackEvents[K]): boolean
144
}
145

146
export class CacheStack extends EventEmitter {
147
  private readonly stampedeGuard: StampedeGuard
148
  private readonly metricsCollector = new MetricsCollector()
223✔
149
  private readonly instanceId = createInstanceId()
223✔
150
  private readonly startup: Promise<void>
151
  private unsubscribeInvalidation?: () => Promise<void> | void
152
  private readonly logger: CacheLogger
153
  private readonly tagIndex: CacheTagIndex
154
  private readonly keyDiscovery: CacheKeyDiscovery
155
  private readonly fetchRateLimiter = new FetchRateLimiter()
223✔
156
  private readonly snapshotSerializer = new JsonSerializer()
223✔
157
  private readonly invalidation: CacheStackInvalidationSupport
158
  private readonly layerWriter: CacheStackLayerWriter
159
  private readonly snapshots: CacheStackSnapshotManager
160
  private readonly backgroundRefreshes = new Map<string, Promise<void>>()
223✔
161
  private readonly backgroundRefreshAbort = new Map<string, boolean>()
223✔
162
  private readonly layerDegradedUntil = new Map<string, number>()
223✔
163
  private readonly maintenance = new CacheStackMaintenance()
223✔
164
  private readonly ttlResolver: TtlResolver
165
  private readonly circuitBreakerManager: CircuitBreakerManager
166
  private nextOperationId = 0
223✔
167
  private currentGeneration?: number
168
  private isDisconnecting = false
223✔
169
  private disconnectPromise?: Promise<void>
170

171
  constructor(
172
    private readonly layers: CacheLayer[],
223✔
173
    private readonly options: CacheStackOptions = {}
223✔
174
  ) {
175
    super()
223✔
176

177
    if (layers.length === 0) {
223✔
178
      throw new Error('CacheStack requires at least one cache layer.')
1✔
179
    }
180

181
    this.validateConfiguration()
222✔
182

183
    const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES
222✔
184
    this.ttlResolver = new TtlResolver({ maxProfileEntries })
223✔
185
    this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries })
223✔
186
    this.stampedeGuard = new StampedeGuard({
223✔
187
      maxInFlight: options.stampedeMaxInFlight,
188
      entryTimeoutMs: options.stampedeEntryTimeoutMs
189
    })
190
    this.currentGeneration = options.generation
223✔
191

192
    if (options.publishSetInvalidation !== undefined) {
223✔
193
      console.warn(
1✔
194
        '[layercache] CacheStackOptions.publishSetInvalidation is deprecated. ' + 'Use broadcastL1Invalidation instead.'
195
      )
196
    }
197

198
    const debugEnv = process.env.DEBUG?.split(',').includes('layercache:debug') ?? false
218✔
199
    this.logger =
223✔
200
      typeof options.logger === 'object' ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv)
638✔
201
    this.tagIndex = options.tagIndex ?? new TagIndex()
223✔
202
    this.keyDiscovery = new CacheKeyDiscovery({
223✔
203
      layers: this.layers,
204
      tagIndex: this.tagIndex,
205
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
16✔
206
      handleLayerFailure: async (layer, operation, error) => {
207
        await this.handleLayerFailure(layer, operation, error)
1✔
208
      }
209
    })
210
    this.invalidation = new CacheStackInvalidationSupport({
223✔
211
      tagIndex: this.tagIndex,
212
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
34✔
213
      handleLayerFailure: async (layer, operation, error) => {
214
        await this.handleLayerFailure(layer, operation, error)
×
215
      }
216
    })
217
    this.layerWriter = new CacheStackLayerWriter({
223✔
218
      layers: this.layers,
219
      maintenance: this.maintenance,
220
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
179✔
221
      shouldWriteBehind: (layer) => this.shouldWriteBehind(layer),
171✔
222
      handleLayerFailure: async (layer, operation, error) => {
223
        await this.handleLayerFailure(layer, operation, error)
4✔
224
      },
225
      enqueueWriteBehind: this.enqueueWriteBehind.bind(this),
226
      resolveFreshTtl: this.resolveFreshTtl.bind(this),
227
      resolveLayerSeconds: this.resolveLayerSeconds.bind(this),
228
      globalStaleWhileRevalidate: this.options.staleWhileRevalidate,
229
      globalStaleIfError: this.options.staleIfError,
230
      writePolicy: this.options.writePolicy,
231
      onWriteFailures: (context, failures) => {
232
        this.metricsCollector.increment('writeFailures', failures.length)
3✔
233
        this.logger.debug?.('write-failure', {
3✔
234
          ...context,
235
          failures: failures.map((failure) => this.formatError(failure))
3✔
236
        })
237
      }
238
    })
239
    if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
248✔
240
      this.logger.warn?.(
21✔
241
        '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.'
242
      )
243
    }
244
    if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
249✔
245
      this.logger.warn?.(
4✔
246
        '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.'
247
      )
248
    }
249
    if (
218✔
250
      options.invalidationBus &&
246✔
251
      options.broadcastL1Invalidation === undefined &&
252
      options.publishSetInvalidation === undefined
253
    ) {
254
      this.logger.warn?.(
12✔
255
        'broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired.'
256
      )
257
    }
258
    this.snapshots = new CacheStackSnapshotManager({
218✔
259
      layers: this.layers,
260
      tagIndex: this.tagIndex,
261
      snapshotSerializer: this.snapshotSerializer,
262
      readLayerEntry: this.readLayerEntry.bind(this),
263
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
3✔
264
      handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
×
265
      qualifyKey: this.qualifyKey.bind(this),
266
      stripQualifiedKey: this.stripQualifiedKey.bind(this),
267
      validateCacheKey,
268
      formatError: this.formatError.bind(this)
269
    })
270
    this.initializeWriteBehind(options.writeBehind)
218✔
271
    this.startup = this.initialize()
218✔
272
  }
273

274
  /**
275
   * Read-through cache get.
276
   * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
277
   * and stores the result across all layers. Returns `null` if the key is not found
278
   * and no `fetcher` is provided.
279
   */
280
  async get<T>(key: string, fetcher?: () => Promise<T>, options?: CacheGetOptions): Promise<T | null> {
281
    return this.observeOperation('layercache.get', { 'layercache.key': String(key ?? '') }, async () => {
273✔
282
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
273✔
283
      this.validateWriteOptions(options)
273✔
284
      await this.awaitStartup('get')
273✔
285
      return this.getPrepared(normalizedKey, fetcher, options)
267✔
286
    })
287
  }
288

289
  private async getPrepared<T>(
290
    normalizedKey: string,
291
    fetcher?: () => Promise<T>,
292
    options?: CacheGetOptions
293
  ): Promise<T | null> {
294
    const hit = await this.readFromLayers<T>(normalizedKey, options, 'allow-stale')
269✔
295
    if (hit.found) {
269✔
296
      this.ttlResolver.recordAccess(normalizedKey)
80✔
297
      if (this.isNegativeStoredValue(hit.stored)) {
80✔
298
        this.metricsCollector.increment('negativeCacheHits')
1✔
299
      }
300

301
      if (hit.state === 'fresh') {
80✔
302
        this.metricsCollector.increment('hits')
67✔
303
        await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher)
67✔
304
        return hit.value
67✔
305
      }
306

307
      if (hit.state === 'stale-while-revalidate') {
13✔
308
        this.metricsCollector.increment('hits')
10✔
309
        this.metricsCollector.increment('staleHits')
10✔
310
        this.emit('stale-serve', { key: normalizedKey, state: hit.state, layer: hit.layerName })
10✔
311
        if (fetcher) {
10!
312
          this.scheduleBackgroundRefresh(normalizedKey, fetcher, options)
10✔
313
        }
314
        return hit.value
10✔
315
      }
316

317
      if (!fetcher) {
3✔
318
        this.metricsCollector.increment('hits')
1✔
319
        this.metricsCollector.increment('staleHits')
1✔
320
        this.emit('stale-serve', { key: normalizedKey, state: hit.state, layer: hit.layerName })
1✔
321
        return hit.value
1✔
322
      }
323

324
      try {
2✔
325
        return await this.fetchWithGuards(normalizedKey, fetcher, options)
2✔
326
      } catch (error) {
327
        this.metricsCollector.increment('staleHits')
2✔
328
        this.metricsCollector.increment('refreshErrors')
2✔
329
        this.logger.debug?.('stale-if-error', { key: normalizedKey, error: this.formatError(error) })
2✔
330
        return hit.value
2✔
331
      }
332
    }
333

334
    this.metricsCollector.increment('misses')
189✔
335
    if (!fetcher) {
189✔
336
      return null
55✔
337
    }
338

339
    return this.fetchWithGuards(normalizedKey, fetcher, options, undefined, undefined, true)
134✔
340
  }
341

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

350
  /**
351
   * Like `get()`, but throws `CacheMissError` instead of returning `null`.
352
   * Useful when the value is expected to exist or the fetcher is expected to
353
   * return non-null.
354
   */
355
  async getOrThrow<T>(key: string, fetcher?: () => Promise<T>, options?: CacheGetOptions): Promise<T> {
356
    const value = await this.get(key, fetcher, options)
4✔
357
    if (value === null) {
4✔
358
      throw new CacheMissError(key)
3✔
359
    }
360
    return value
1✔
361
  }
362

363
  /**
364
   * Returns true if the given key exists and is not expired in any layer.
365
   */
366
  async has(key: string): Promise<boolean> {
367
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
8✔
368
    await this.awaitStartup('has')
8✔
369

370
    for (const layer of this.layers) {
8✔
371
      if (this.shouldSkipLayer(layer)) {
15!
372
        continue
×
373
      }
374
      if (layer.has) {
15✔
375
        try {
5✔
376
          const exists = await layer.has(normalizedKey)
5✔
377
          if (exists) {
4✔
378
            return true
2✔
379
          }
380
        } catch {
381
          await this.reportRecoverableLayerFailure(layer, 'has', new Error(`has() failed for layer "${layer.name}"`))
1✔
382
          // fall through to next layer
383
        }
384
      } else {
385
        try {
10✔
386
          const value = await layer.get(normalizedKey)
10✔
387
          if (value !== null) {
7✔
388
            return true
2✔
389
          }
390
        } catch (error) {
391
          await this.reportRecoverableLayerFailure(layer, 'has', error)
3✔
392
          // fall through
393
        }
394
      }
395
    }
396
    return false
4✔
397
  }
398

399
  /**
400
   * Returns the remaining TTL in seconds for the key in the fastest layer
401
   * that has it, or null if the key is not found / has no TTL.
402
   */
403
  async ttl(key: string): Promise<number | null> {
404
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
4✔
405
    await this.awaitStartup('ttl')
4✔
406

407
    for (const layer of this.layers) {
4✔
408
      if (this.shouldSkipLayer(layer)) {
8✔
409
        continue
1✔
410
      }
411
      if (layer.ttl) {
7✔
412
        try {
6✔
413
          const remaining = await layer.ttl(normalizedKey)
6✔
414
          if (remaining !== null) {
4✔
415
            return remaining
3✔
416
          }
417
        } catch {
418
          // fall through
419
        }
420
      }
421
    }
422
    return null
1✔
423
  }
424

425
  /**
426
   * Stores a value in all cache layers. Overwrites any existing value.
427
   */
428
  async set<T>(key: string, value: T, options?: CacheWriteOptions): Promise<void> {
429
    await this.observeOperation('layercache.set', { 'layercache.key': String(key ?? '') }, async () => {
101✔
430
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
101✔
431
      this.validateWriteOptions(options)
101✔
432
      await this.awaitStartup('set')
101✔
433
      await this.storeEntry(normalizedKey, 'value', value, options)
97✔
434
    })
435
  }
436

437
  /**
438
   * Deletes the key from all layers and publishes an invalidation message.
439
   */
440
  async delete(key: string): Promise<void> {
441
    await this.observeOperation('layercache.delete', { 'layercache.key': String(key ?? '') }, async () => {
7✔
442
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
7✔
443
      await this.awaitStartup('delete')
7✔
444
      await this.deleteKeys([normalizedKey])
6✔
445
      await this.publishInvalidation({
6✔
446
        scope: 'key',
447
        keys: [normalizedKey],
448
        sourceId: this.instanceId,
449
        operation: 'delete'
450
      })
451
    })
452
  }
453

454
  async clear(): Promise<void> {
455
    await this.awaitStartup('clear')
4✔
456
    this.maintenance.beginClearEpoch()
4✔
457
    await Promise.all(this.layers.map((layer) => layer.clear()))
5✔
458
    await this.tagIndex.clear()
4✔
459
    this.ttlResolver.clearProfiles()
4✔
460
    this.circuitBreakerManager.clear()
4✔
461
    this.metricsCollector.increment('invalidations')
4✔
462
    this.logger.debug?.('clear')
4✔
463
    await this.publishInvalidation({ scope: 'clear', sourceId: this.instanceId, operation: 'clear' })
4✔
464
  }
465

466
  /**
467
   * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
468
   */
469
  async mdelete(keys: string[]): Promise<void> {
470
    if (keys.length === 0) {
3✔
471
      return
1✔
472
    }
473
    await this.awaitStartup('mdelete')
2✔
474
    const normalizedKeys = keys.map((k) => validateCacheKey(k))
3✔
475
    const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key))
3✔
476
    await this.deleteKeys(cacheKeys)
2✔
477
    await this.publishInvalidation({
2✔
478
      scope: 'keys',
479
      keys: cacheKeys,
480
      sourceId: this.instanceId,
481
      operation: 'delete'
482
    })
483
  }
484

485
  async mget<T>(entries: CacheMGetEntry<T>[]): Promise<Array<T | null>> {
486
    return this.observeOperation('layercache.mget', undefined, async () => {
9✔
487
      this.assertActive('mget')
9✔
488
      if (entries.length === 0) {
9✔
489
        return []
1✔
490
      }
491

492
      const normalizedEntries = entries.map((entry) => ({
18✔
493
        ...entry,
494
        key: this.qualifyKey(validateCacheKey(entry.key))
495
      }))
496
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
18✔
497
      const canFastPath = normalizedEntries.every((entry) => entry.fetch === undefined && entry.options === undefined)
16✔
498
      if (!canFastPath) {
8✔
499
        await this.awaitStartup('mget')
2✔
500
        const pendingReads = new Map<
2✔
501
          string,
502
          {
503
            promise: Promise<T | null>
504
            fetch?: () => Promise<T>
505
            optionsSignature: string
506
          }
507
        >()
508

509
        return Promise.all(
2✔
510
          normalizedEntries.map((entry) => {
511
            const optionsSignature = serializeOptions(entry.options)
4✔
512
            const existing = pendingReads.get(entry.key)
4✔
513
            if (!existing) {
4✔
514
              const promise = this.getPrepared(entry.key, entry.fetch, entry.options)
2✔
515
              pendingReads.set(entry.key, {
2✔
516
                promise,
517
                fetch: entry.fetch,
518
                optionsSignature
519
              })
520
              return promise
2✔
521
            }
522

523
            if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2!
524
              const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key
2!
525
              throw new Error(`mget received conflicting entries for key "${displayKey}".`)
2✔
526
            }
527

528
            return existing.promise
×
529
          })
530
        )
531
      }
532

533
      await this.awaitStartup('mget')
6✔
534
      const pending = new Set<string>()
6✔
535
      const indexesByKey = new Map<string, number[]>()
6✔
536
      const resultsByKey = new Map<string, T | null>()
6✔
537

538
      for (let index = 0; index < normalizedEntries.length; index += 1) {
6✔
539
        const entry = normalizedEntries[index]
14✔
540
        if (!entry) continue
14!
541
        const key = entry.key
14✔
542
        const indexes = indexesByKey.get(key) ?? []
14✔
543
        indexes.push(index)
14✔
544
        indexesByKey.set(key, indexes)
14✔
545
        pending.add(key)
14✔
546
      }
547

548
      for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
6✔
549
        const layer = this.layers[layerIndex]
6✔
550
        if (!layer || this.shouldSkipLayer(layer)) continue
6!
551
        const keys = [...pending]
6✔
552
        if (keys.length === 0) {
6!
553
          break
×
554
        }
555

556
        const values = layer.getMany
6!
557
          ? await layer.getMany(keys)
558
          : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)))
×
559

560
        for (let offset = 0; offset < values.length; offset += 1) {
×
561
          const key = keys[offset]
13✔
562
          const stored = values[offset]
13✔
563
          if (!key || stored === null) {
13✔
564
            continue
2✔
565
          }
566

567
          const resolved = resolveStoredValue<T>(stored)
11✔
568
          if (resolved.state === 'expired') {
11✔
569
            await layer.delete(key)
1✔
570
            continue
1✔
571
          }
572

573
          if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
10!
574
            this.metricsCollector.increment('staleHits', indexesByKey.get(key)?.length ?? 1)
×
575
          }
576

577
          await this.tagIndex.touch(key)
10✔
578
          await this.backfill(key, stored, layerIndex - 1)
10✔
579
          resultsByKey.set(key, resolved.value)
10✔
580
          pending.delete(key)
10✔
581
          this.metricsCollector.increment('hits', indexesByKey.get(key)?.length ?? 1)
10!
582
        }
583
      }
584

585
      if (pending.size > 0) {
6✔
586
        for (const key of pending) {
2✔
587
          await this.tagIndex.remove(key)
3✔
588
          this.metricsCollector.increment('misses', indexesByKey.get(key)?.length ?? 1)
3!
589
        }
590
      }
591

592
      return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null)
14✔
593
    })
594
  }
595

596
  async mset<T>(entries: CacheMSetEntry<T>[]): Promise<void> {
597
    await this.observeOperation('layercache.mset', undefined, async () => {
9✔
598
      this.assertActive('mset')
9✔
599
      const normalizedEntries = entries.map((entry) => ({
18✔
600
        ...entry,
601
        key: this.qualifyKey(validateCacheKey(entry.key))
602
      }))
603
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
18✔
604
      await this.awaitStartup('mset')
9✔
605
      await this.writeBatch(normalizedEntries)
9✔
606
    })
607
  }
608

609
  async warm(entries: CacheWarmEntry[], options: CacheWarmOptions = {}): Promise<void> {
4✔
610
    this.assertActive('warm')
4✔
611
    const concurrency = Math.max(1, options.concurrency ?? 4)
4✔
612
    const total = entries.length
4✔
613
    let completed = 0
4✔
614
    const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
4!
615
    const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
4!
616
      while (queue.length > 0) {
4✔
617
        const entry = queue.shift()
6✔
618
        if (!entry) {
6!
619
          return
×
620
        }
621

622
        let success = false
6✔
623
        try {
6✔
624
          await this.get(entry.key, entry.fetcher, entry.options)
6✔
625
          this.emit('warm', { key: entry.key })
4✔
626
          success = true
4✔
627
        } catch (error) {
628
          this.emitError('warm', { key: entry.key, error: this.formatError(error) })
2✔
629
          if (!options.continueOnError) {
2✔
630
            throw error
1✔
631
          }
632
        } finally {
633
          completed += 1
6✔
634
          const progress: CacheWarmProgress = { completed, total, key: entry.key, success }
6✔
635
          options.onProgress?.(progress)
6✔
636
        }
637
      }
638
    })
639

640
    await Promise.all(workers)
4✔
641
  }
642

643
  /**
644
   * Returns a cached version of `fetcher`. The cache key is derived from
645
   * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
646
   */
647
  wrap<TArgs extends unknown[], TResult>(
648
    prefix: string,
649
    fetcher: (...args: TArgs) => Promise<TResult>,
650
    options: CacheWrapOptions<TArgs> = {}
9✔
651
  ): (...args: TArgs) => Promise<TResult | null> {
652
    return (...args: TArgs) => {
9✔
653
      const suffix = options.keyResolver
16✔
654
        ? options.keyResolver(...args)
655
        : args.map((argument) => serializeKeyPart(argument)).join(':')
9✔
656
      const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix
16!
657
      return this.get<TResult>(key, () => fetcher(...args), options)
16✔
658
    }
659
  }
660

661
  /**
662
   * Creates a `CacheNamespace` that automatically prefixes all keys with
663
   * `prefix:`. Useful for multi-tenant or module-level isolation.
664
   */
665
  namespace(prefix: string): CacheNamespace {
666
    validateNamespaceKey(prefix)
36✔
667
    return new CacheNamespace(this, prefix)
36✔
668
  }
669

670
  async invalidateByTag(tag: string): Promise<void> {
671
    await this.observeOperation('layercache.invalidate_by_tag', undefined, async () => {
8✔
672
      validateTag(tag)
8✔
673
      await this.awaitStartup('invalidateByTag')
8✔
674
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
7✔
675
      await this.deleteKeys(keys)
6✔
676
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
677
    })
678
  }
679

680
  async invalidateByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
4✔
681
    await this.observeOperation('layercache.invalidate_by_tags', undefined, async () => {
4✔
682
      if (tags.length === 0) {
4!
683
        return
×
684
      }
685

686
      validateTags(tags)
4✔
687
      await this.awaitStartup('invalidateByTags')
4✔
688
      const keysByTag = await Promise.all(
4✔
689
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
7✔
690
      )
691
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
3✔
692
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
4✔
693

694
      await this.deleteKeys(keys)
4✔
695
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
696
    })
697
  }
698

699
  async invalidateByPattern(pattern: string): Promise<void> {
700
    await this.observeOperation('layercache.invalidate_by_pattern', undefined, async () => {
5✔
701
      validatePattern(pattern)
5✔
702
      await this.awaitStartup('invalidateByPattern')
5✔
703
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
5✔
704
        this.qualifyPattern(pattern),
705
        this.invalidationMaxKeys()
706
      )
707
      await this.deleteKeys(keys)
4✔
708
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
4✔
709
    })
710
  }
711

712
  async invalidateByPrefix(prefix: string): Promise<void> {
713
    await this.observeOperation('layercache.invalidate_by_prefix', undefined, async () => {
7✔
714
      await this.awaitStartup('invalidateByPrefix')
7✔
715
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
7✔
716
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
7✔
717
      await this.deleteKeys(keys)
6✔
718
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
719
    })
720
  }
721

722
  getMetrics(): CacheMetricsSnapshot {
723
    return this.metricsCollector.snapshot
189✔
724
  }
725

726
  getStats(): CacheStatsSnapshot {
727
    return {
29✔
728
      metrics: this.getMetrics(),
729
      layers: this.layers.map((layer) => ({
33✔
730
        name: layer.name,
731
        isLocal: Boolean(layer.isLocal),
732
        degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
62✔
733
      })),
734
      backgroundRefreshes: this.backgroundRefreshes.size
735
    }
736
  }
737

738
  resetMetrics(): void {
739
    this.metricsCollector.reset()
×
740
  }
741

742
  /**
743
   * Returns computed hit-rate statistics (overall and per-layer).
744
   */
745
  getHitRate(): CacheHitRateSnapshot {
746
    return this.metricsCollector.hitRate()
6✔
747
  }
748

749
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
750
    await this.startup
2✔
751

752
    return Promise.all(
2✔
753
      this.layers.map(async (layer) => {
754
        const startedAt = performance.now()
4✔
755
        try {
4✔
756
          const healthy = layer.ping ? await layer.ping() : true
4✔
757
          return {
4✔
758
            layer: layer.name,
759
            healthy,
760
            latencyMs: performance.now() - startedAt
761
          }
762
        } catch (error) {
763
          return {
1✔
764
            layer: layer.name,
765
            healthy: false,
766
            latencyMs: performance.now() - startedAt,
767
            error: this.formatError(error)
768
          }
769
        }
770
      })
771
    )
772
  }
773

774
  /**
775
   * Rotates the active generation prefix used for all future cache keys.
776
   * Previous-generation keys remain in the underlying layers until they expire,
777
   * unless `generationCleanup` is enabled to prune them in the background.
778
   */
779
  bumpGeneration(nextGeneration?: number): number {
780
    const current = this.currentGeneration ?? 0
3!
781
    const previousGeneration = this.currentGeneration
3✔
782
    const updatedGeneration = nextGeneration ?? current + 1
3✔
783
    const generationToCleanup = resolveGenerationCleanupTarget({
3✔
784
      previousGeneration,
785
      nextGeneration: updatedGeneration,
786
      generationCleanup: this.options.generationCleanup
787
    })
788

789
    this.currentGeneration = updatedGeneration
3✔
790
    if (generationToCleanup !== null) {
3✔
791
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
792
    }
793

794
    return this.currentGeneration
3✔
795
  }
796

797
  /**
798
   * Returns detailed metadata about a single cache key: which layers contain it,
799
   * remaining fresh/stale/error TTLs, and associated tags.
800
   * Returns `null` if the key does not exist in any layer.
801
   */
802
  async inspect(key: string): Promise<CacheInspectResult | null> {
803
    const userKey = validateCacheKey(key)
3✔
804
    const normalizedKey = this.qualifyKey(userKey)
3✔
805
    await this.awaitStartup('inspect')
3✔
806

807
    const foundInLayers: string[] = []
3✔
808
    let freshTtlSeconds: number | null = null
3✔
809
    let staleTtlSeconds: number | null = null
3✔
810
    let errorTtlSeconds: number | null = null
3✔
811
    let isStale = false
3✔
812

813
    for (const layer of this.layers) {
3✔
814
      if (this.shouldSkipLayer(layer)) {
3!
815
        continue
×
816
      }
817
      const stored = await this.readLayerEntry(layer, normalizedKey)
3✔
818
      if (stored === null) {
3✔
819
        continue
1✔
820
      }
821

822
      const resolved = resolveStoredValue(stored)
2✔
823
      if (resolved.state === 'expired') {
2!
824
        continue
×
825
      }
826

827
      foundInLayers.push(layer.name)
2✔
828

829
      // Take TTL info from the first (fastest) layer that has it
830
      if (foundInLayers.length === 1 && resolved.envelope) {
2!
831
        const now = Date.now()
2✔
832
        freshTtlSeconds =
2✔
833
          resolved.envelope.freshUntil !== null
2!
834
            ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1_000))
835
            : null
836
        staleTtlSeconds =
2✔
837
          resolved.envelope.staleUntil !== null
2✔
838
            ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1_000))
839
            : null
840
        errorTtlSeconds =
2✔
841
          resolved.envelope.errorUntil !== null
2✔
842
            ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1_000))
843
            : null
844
        isStale = resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error'
2✔
845
      }
846
    }
847

848
    if (foundInLayers.length === 0) {
3✔
849
      return null
1✔
850
    }
851

852
    const tags = await this.getTagsForKey(normalizedKey)
2✔
853

854
    return { key: userKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags }
2✔
855
  }
856

857
  async exportState(): Promise<CacheSnapshotEntry[]> {
858
    await this.awaitStartup('exportState')
2✔
859
    return this.snapshots.exportState(this.snapshotMaxEntries())
2✔
860
  }
861

862
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
863
    await this.awaitStartup('importState')
1✔
864
    await this.snapshots.importState(entries)
1✔
865
  }
866

867
  async persistToFile(filePath: string): Promise<void> {
868
    this.assertActive('persistToFile')
4✔
869
    await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries())
4✔
870
  }
871

872
  async restoreFromFile(filePath: string): Promise<void> {
873
    this.assertActive('restoreFromFile')
8✔
874
    await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes())
8✔
875
  }
876

877
  async disconnect(): Promise<void> {
878
    if (!this.disconnectPromise) {
26!
879
      this.isDisconnecting = true
26✔
880
      this.disconnectPromise = (async () => {
26✔
881
        await this.startup
26✔
882
        await this.unsubscribeInvalidation?.()
26✔
883
        await this.flushWriteBehindQueue()
26✔
884
        await this.maintenance.waitForGenerationCleanup()
26✔
885
        // Signal all background refreshes to abort, then wait with a timeout
886
        for (const key of this.backgroundRefreshAbort.keys()) {
26✔
887
          this.backgroundRefreshAbort.set(key, true)
×
888
        }
889
        await Promise.allSettled(
26✔
890
          [...this.backgroundRefreshes.values()].map((promise) => {
891
            let timer: ReturnType<typeof setTimeout> | undefined
892
            return Promise.race([
×
893
              promise,
894
              new Promise<void>((resolve) => {
895
                timer = setTimeout(resolve, 5_000)
×
896
                timer.unref?.()
×
897
              })
898
            ]).finally(() => {
899
              if (timer) clearTimeout(timer)
×
900
            })
901
          })
902
        )
903
        this.backgroundRefreshes.clear()
26✔
904
        this.backgroundRefreshAbort.clear()
26✔
905
        this.maintenance.disposeWriteBehindTimer()
26✔
906
        this.fetchRateLimiter.dispose()
26✔
907
        await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()))
38✔
908
      })()
909
    }
910

911
    await this.disconnectPromise
26✔
912
  }
913

914
  private async initialize(): Promise<void> {
915
    if (!this.options.invalidationBus) {
218✔
916
      return
203✔
917
    }
918

919
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
15✔
920
      await this.handleInvalidationMessage(message)
10✔
921
    })
922
  }
923

924
  private async fetchWithGuards<T>(
925
    key: string,
926
    fetcher: () => Promise<T>,
927
    options?: CacheGetOptions,
928
    expectedClearEpoch?: number,
929
    expectedKeyEpoch?: number,
930
    initialMissConfirmed = false
145✔
931
  ): Promise<T | null> {
932
    const fetchTask = async (): Promise<T | null> => {
145✔
933
      const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator)
68✔
934
      if (shouldRecheckFreshLayers) {
68✔
935
        const secondHit = await this.readFromLayers<T>(key, options, 'fresh-only')
64✔
936
        if (secondHit.found) {
64✔
937
          this.metricsCollector.increment('hits')
1✔
938
          return secondHit.value
1✔
939
        }
940
      }
941

942
      return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
67✔
943
    }
944

945
    const singleFlightTask = async (): Promise<T | null> => {
145✔
946
      if (!this.options.singleFlightCoordinator) {
71✔
947
        return fetchTask()
64✔
948
      }
949

950
      try {
7✔
951
        return await this.options.singleFlightCoordinator.execute(
7✔
952
          key,
953
          this.resolveSingleFlightOptions(),
954
          fetchTask,
955
          () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
2✔
956
        )
957
      } catch (error) {
958
        if (!this.isGracefulDegradationEnabled()) {
2✔
959
          throw error
1✔
960
        }
961

962
        this.metricsCollector.increment('degradedOperations')
1✔
963
        this.logger.warn?.('single-flight-coordinator-degraded', { key, error: this.formatError(error) })
1✔
964
        this.emitError('single-flight', { key, degraded: true, error: this.formatError(error) })
2✔
965
        return fetchTask()
2✔
966
      }
967
    }
968

969
    if (this.options.stampedePrevention === false) {
145✔
970
      return singleFlightTask()
2✔
971
    }
972

973
    return this.stampedeGuard.execute(key, singleFlightTask)
143✔
974
  }
975

976
  private async waitForFreshValue<T>(
977
    key: string,
978
    fetcher: () => Promise<T>,
979
    options?: CacheGetOptions,
980
    expectedClearEpoch?: number,
981
    expectedKeyEpoch?: number
982
  ): Promise<T | null> {
983
    const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS
2✔
984
    const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
2✔
985
    const deadline = Date.now() + timeoutMs
2✔
986

987
    this.metricsCollector.increment('singleFlightWaits')
2✔
988
    this.emit('stampede-dedupe', { key })
2✔
989

990
    while (Date.now() < deadline) {
2✔
991
      const hit = await this.readFromLayers<T>(key, options, 'fresh-only')
6✔
992
      if (hit.found) {
6✔
993
        this.metricsCollector.increment('hits')
1✔
994
        return hit.value
1✔
995
      }
996
      await this.sleep(pollIntervalMs)
5✔
997
    }
998

999
    return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
1✔
1000
  }
1001

1002
  private async fetchAndPopulate<T>(
1003
    key: string,
1004
    fetcher: () => Promise<T>,
1005
    options?: CacheGetOptions,
1006
    expectedClearEpoch?: number,
1007
    expectedKeyEpoch?: number
1008
  ): Promise<T | null> {
1009
    this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker)
68✔
1010
    this.metricsCollector.increment('fetches')
68✔
1011
    const fetchStart = Date.now()
68✔
1012
    let fetched: T
1013

1014
    try {
68✔
1015
      fetched = await this.fetchRateLimiter.schedule(
68✔
1016
        options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
127✔
1017
        { key, fetcher },
1018
        fetcher
1019
      )
1020
      this.circuitBreakerManager.recordSuccess(key)
52✔
1021
      this.logger.debug?.('fetch', { key, durationMs: Date.now() - fetchStart })
52✔
1022
    } catch (error) {
1023
      this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error)
12✔
1024
      throw error
12✔
1025
    }
1026

1027
    if (fetched === null || fetched === undefined) {
52✔
1028
      if (!this.shouldNegativeCache(options)) {
5✔
1029
        return null
3✔
1030
      }
1031

1032
      if (this.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
2✔
1033
        this.logger.debug?.('skip-negative-store-after-invalidation', {
1✔
1034
          key,
1035
          expectedClearEpoch,
1036
          clearEpoch: this.maintenance.currentClearEpoch(),
1037
          expectedKeyEpoch,
1038
          keyEpoch: this.maintenance.currentKeyEpoch(key)
1039
        })
1040
        return null
1✔
1041
      }
1042

1043
      await this.storeEntry(key, 'empty', null, options)
1✔
1044
      return null
1✔
1045
    }
1046

1047
    // Conditional caching: skip storage if shouldCache returns false
1048
    if (options?.shouldCache) {
47✔
1049
      try {
2✔
1050
        if (!options.shouldCache(fetched)) {
2✔
1051
          return fetched
1✔
1052
        }
1053
      } catch (error) {
1054
        this.logger.warn?.('shouldCache-error', { key, error: this.formatError(error) })
1✔
1055
      }
1056
    }
1057

1058
    if (this.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
46✔
1059
      this.logger.debug?.('skip-store-after-invalidation', {
2✔
1060
        key,
1061
        expectedClearEpoch,
1062
        clearEpoch: this.maintenance.currentClearEpoch(),
1063
        expectedKeyEpoch,
1064
        keyEpoch: this.maintenance.currentKeyEpoch(key)
1065
      })
1066
      return fetched
2✔
1067
    }
1068

1069
    await this.storeEntry(key, 'value', fetched, options)
44✔
1070
    return fetched
44✔
1071
  }
1072

1073
  private async storeEntry(
1074
    key: string,
1075
    kind: CacheWriteKind,
1076
    value: unknown,
1077
    options?: CacheWriteOptions
1078
  ): Promise<void> {
1079
    const clearEpoch = this.maintenance.currentClearEpoch()
142✔
1080
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
142✔
1081
    await this.layerWriter.writeAcrossLayers(key, kind, value, options)
142✔
1082
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
142!
1083
      return
×
1084
    }
1085
    if (options?.tags) {
142✔
1086
      await this.tagIndex.track(key, options.tags)
20✔
1087
    } else {
1088
      await this.tagIndex.touch(key)
122✔
1089
    }
1090

1091
    this.metricsCollector.increment('sets')
142✔
1092
    this.logger.debug?.('set', { key, kind, tags: options?.tags })
142✔
1093
    this.emit('set', { key, kind: kind as string, tags: options?.tags })
142✔
1094
    if (this.shouldBroadcastL1Invalidation()) {
142✔
1095
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
1096
    }
1097
  }
1098

1099
  private async writeBatch(
1100
    entries: Array<{ key: string; value: unknown; options?: CacheWriteOptions }>
1101
  ): Promise<void> {
1102
    const { clearEpoch, entryEpochs } = await this.layerWriter.writeBatch(entries)
9✔
1103
    if (clearEpoch !== this.maintenance.currentClearEpoch()) {
8!
1104
      return
×
1105
    }
1106

1107
    for (const entry of entries) {
8✔
1108
      if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
16!
1109
        continue
×
1110
      }
1111
      if (entry.options?.tags) {
16!
1112
        await this.tagIndex.track(entry.key, entry.options.tags)
×
1113
      } else {
1114
        await this.tagIndex.touch(entry.key)
16✔
1115
      }
1116

1117
      this.metricsCollector.increment('sets')
16✔
1118
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
16✔
1119
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
16✔
1120
    }
1121

1122
    if (this.shouldBroadcastL1Invalidation()) {
8!
1123
      await this.publishInvalidation({
×
1124
        scope: 'keys',
1125
        keys: entries.map((entry) => entry.key),
×
1126
        sourceId: this.instanceId,
1127
        operation: 'write'
1128
      })
1129
    }
1130
  }
1131

1132
  private async readFromLayers<T>(
1133
    key: string,
1134
    options: CacheGetOptions | undefined,
1135
    mode: ReadMode
1136
  ): Promise<ReadHit<T>> {
1137
    let sawRetainableValue = false
339✔
1138

1139
    for (let index = 0; index < this.layers.length; index += 1) {
339✔
1140
      const layer = this.layers[index]
359✔
1141
      if (!layer) continue
359!
1142
      const readStart = performance.now()
359✔
1143
      const stored = await this.readLayerEntry(layer, key)
359✔
1144
      const readDuration = performance.now() - readStart
359✔
1145
      this.metricsCollector.recordLatency(layer.name, readDuration)
359✔
1146
      if (stored === null) {
359✔
1147
        this.metricsCollector.incrementLayer('missesByLayer', layer.name)
266✔
1148
        continue
266✔
1149
      }
1150

1151
      const resolved = resolveStoredValue<T>(stored)
93✔
1152
      if (resolved.state === 'expired') {
93!
1153
        await layer.delete(key)
×
1154
        continue
×
1155
      }
1156

1157
      sawRetainableValue = true
93✔
1158

1159
      if (mode === 'fresh-only' && resolved.state !== 'fresh') {
93✔
1160
        continue
11✔
1161
      }
1162

1163
      await this.tagIndex.touch(key)
82✔
1164
      await this.backfill(key, stored, index - 1, options)
82✔
1165
      this.metricsCollector.incrementLayer('hitsByLayer', layer.name)
82✔
1166
      this.logger.debug?.('hit', { key, layer: layer.name, state: resolved.state })
82✔
1167
      this.emit('hit', { key, layer: layer.name, state: resolved.state as CacheStackEvents['hit']['state'] })
359✔
1168
      return {
359✔
1169
        found: true,
1170
        value: resolved.value,
1171
        stored,
1172
        state: resolved.state,
1173
        layerIndex: index,
1174
        layerName: layer.name
1175
      }
1176
    }
1177

1178
    if (!sawRetainableValue) {
257✔
1179
      await this.tagIndex.remove(key)
246✔
1180
    }
1181

1182
    this.logger.debug?.('miss', { key, mode })
257✔
1183
    this.emit('miss', { key, mode })
339✔
1184
    return { found: false, value: null, stored: null, state: 'miss' }
339✔
1185
  }
1186

1187
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1188
    if (this.shouldSkipLayer(layer)) {
367✔
1189
      return null
2✔
1190
    }
1191

1192
    if (layer.getEntry) {
365✔
1193
      try {
356✔
1194
        return await layer.getEntry(key)
356✔
1195
      } catch (error) {
1196
        return this.handleLayerFailure(layer, 'read', error)
2✔
1197
      }
1198
    }
1199

1200
    try {
9✔
1201
      return await layer.get(key)
9✔
1202
    } catch (error) {
1203
      return this.handleLayerFailure(layer, 'read', error)
2✔
1204
    }
1205
  }
1206

1207
  private async backfill(key: string, stored: unknown, upToIndex: number, options?: CacheGetOptions): Promise<void> {
1208
    if (upToIndex < 0) {
92✔
1209
      return
80✔
1210
    }
1211

1212
    for (let index = 0; index <= upToIndex; index += 1) {
12✔
1213
      const layer = this.layers[index]
12✔
1214
      if (!layer || this.shouldSkipLayer(layer)) {
12✔
1215
        continue
3✔
1216
      }
1217

1218
      const ttl =
9✔
1219
        remainingStoredTtlSeconds(stored) ??
1220
        this.resolveLayerSeconds(layer.name, options?.ttl, undefined, layer.defaultTtl)
1221
      try {
12✔
1222
        await layer.set(key, stored, ttl)
12✔
1223
      } catch (error) {
1224
        await this.handleLayerFailure(layer, 'backfill', error)
×
1225
        continue
×
1226
      }
1227
      this.metricsCollector.increment('backfills')
9✔
1228
      this.logger.debug?.('backfill', { key, layer: layer.name })
9✔
1229
      this.emit('backfill', { key, layer: layer.name })
12✔
1230
    }
1231
  }
1232

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

1253
  private resolveLayerSeconds(
1254
    layerName: string,
1255
    override: number | LayerTtlMap | undefined,
1256
    globalDefault?: number | LayerTtlMap,
1257
    fallback?: number
1258
  ): number | undefined {
1259
    return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback)
428✔
1260
  }
1261

1262
  private shouldNegativeCache(options?: CacheGetOptions): boolean {
1263
    return options?.negativeCache ?? this.options.negativeCaching ?? false
5✔
1264
  }
1265

1266
  private scheduleBackgroundRefresh<T>(key: string, fetcher: () => Promise<T>, options?: CacheGetOptions): void {
1267
    if (
10✔
1268
      !shouldStartBackgroundRefresh({
1269
        isDisconnecting: this.isDisconnecting,
1270
        hasRefreshInFlight: this.backgroundRefreshes.has(key)
1271
      })
1272
    ) {
1273
      return
1✔
1274
    }
1275

1276
    const clearEpoch = this.maintenance.currentClearEpoch()
9✔
1277
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
9✔
1278
    this.backgroundRefreshAbort.set(key, false)
9✔
1279
    const refresh = (async () => {
9✔
1280
      this.metricsCollector.increment('refreshes')
9✔
1281
      try {
9✔
1282
        if (this.backgroundRefreshAbort.get(key)) return
9!
1283
        await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch)
9✔
1284
      } catch (error) {
1285
        if (this.backgroundRefreshAbort.get(key)) return
4!
1286
        this.metricsCollector.increment('refreshErrors')
4✔
1287
        this.logger.warn?.('background-refresh-error', { key, error: this.formatError(error) })
4✔
1288
      } finally {
1289
        this.backgroundRefreshes.delete(key)
9✔
1290
        this.backgroundRefreshAbort.delete(key)
9✔
1291
      }
1292
    })()
1293

1294
    this.backgroundRefreshes.set(key, refresh)
9✔
1295
  }
1296

1297
  private async runBackgroundRefresh<T>(
1298
    key: string,
1299
    fetcher: () => Promise<T>,
1300
    options?: CacheGetOptions,
1301
    expectedClearEpoch?: number,
1302
    expectedKeyEpoch?: number
1303
  ): Promise<void> {
1304
    const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS
9✔
1305
    await this.fetchWithGuards(
9✔
1306
      key,
1307
      () =>
1308
        this.withTimeout(fetcher(), timeoutMs, () => {
9✔
1309
          return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`)
4✔
1310
        }),
1311
      options,
1312
      expectedClearEpoch,
1313
      expectedKeyEpoch
1314
    )
1315
  }
1316

1317
  private resolveSingleFlightOptions(): CacheSingleFlightExecutionOptions {
1318
    return {
7✔
1319
      leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
14✔
1320
      waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
13✔
1321
      pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
13✔
1322
      renewIntervalMs: this.options.singleFlightRenewIntervalMs
1323
    }
1324
  }
1325

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

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

1334
    for (const key of keys) {
24✔
1335
      await this.tagIndex.remove(key)
31✔
1336
      this.ttlResolver.deleteProfile(key)
31✔
1337
      this.circuitBreakerManager.delete(key)
31✔
1338
    }
1339

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

1346
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1347
    if (!this.options.invalidationBus) {
34✔
1348
      return
29✔
1349
    }
1350

1351
    await this.options.invalidationBus.publish(message)
5✔
1352
  }
1353

1354
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1355
    if (message.sourceId === this.instanceId) {
13✔
1356
      return
6✔
1357
    }
1358

1359
    const localLayers = this.layers.filter((layer) => layer.isLocal)
10✔
1360
    if (message.scope === 'clear') {
7✔
1361
      this.maintenance.beginClearEpoch()
2✔
1362
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1363
      await this.tagIndex.clear()
2✔
1364
      this.ttlResolver.clearProfiles()
2✔
1365
      this.circuitBreakerManager.clear()
2✔
1366
      return
2✔
1367
    }
1368

1369
    const keys = message.keys ?? []
5!
1370
    this.maintenance.bumpKeyEpochs(keys)
13✔
1371
    await this.invalidation.deleteKeysFromLayers(localLayers, keys)
13✔
1372

1373
    if (message.operation !== 'write') {
5✔
1374
      for (const key of keys) {
2✔
1375
        await this.tagIndex.remove(key)
3✔
1376
        this.ttlResolver.deleteProfile(key)
3✔
1377
        this.circuitBreakerManager.delete(key)
3✔
1378
      }
1379
    }
1380
  }
1381

1382
  private async getTagsForKey(key: string): Promise<string[]> {
1383
    if (this.tagIndex.tagsForKey) {
4✔
1384
      return this.tagIndex.tagsForKey(key)
3✔
1385
    }
1386
    return []
1✔
1387
  }
1388

1389
  private formatError(error: unknown): string {
1390
    if (error instanceof Error) {
43✔
1391
      return error.message
42✔
1392
    }
1393

1394
    return String(error)
1✔
1395
  }
1396

1397
  private sleep(ms: number): Promise<void> {
1398
    return new Promise((resolve) => setTimeout(resolve, ms))
5✔
1399
  }
1400

1401
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1402
    if (timeoutMs <= 0) {
11✔
1403
      return promise
1✔
1404
    }
1405

1406
    let timer: ReturnType<typeof setTimeout> | undefined
1407
    const observedPromise = promise.then(
10✔
1408
      (value) => ({ kind: 'value' as const, value }),
5✔
1409
      (error) => ({ kind: 'error' as const, error })
2✔
1410
    )
1411
    try {
10✔
1412
      const result = await Promise.race([
10✔
1413
        observedPromise,
1414
        new Promise<T>((_, reject) => {
1415
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
10✔
1416
          timer.unref?.()
10✔
1417
        })
1418
      ])
1419
      if (result !== null && result !== undefined && typeof result === 'object' && 'kind' in result) {
6!
1420
        if (result.kind === 'error') {
6✔
1421
          throw result.error
1✔
1422
        }
1423
        return result.value
5✔
1424
      }
1425
      return result
×
1426
    } finally {
1427
      if (timer) {
10!
1428
        clearTimeout(timer)
10✔
1429
      }
1430
    }
1431
  }
1432

1433
  private shouldBroadcastL1Invalidation(): boolean {
1434
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
150✔
1435
  }
1436

1437
  private async observeOperation<T>(
1438
    name: string,
1439
    attributes: Record<string, unknown> | undefined,
1440
    execute: () => Promise<T>
1441
  ): Promise<T> {
1442
    const id = this.nextOperationId
423✔
1443
    this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER
423✔
1444
    this.emit('operation-start', { id, name, attributes })
423✔
1445

1446
    try {
423✔
1447
      const result = await execute()
423✔
1448
      this.emit('operation-end', {
392✔
1449
        id,
1450
        name,
1451
        attributes,
1452
        success: true,
1453
        result: result === null ? 'null' : undefined
392✔
1454
      })
1455
      return result
423✔
1456
    } catch (error) {
1457
      this.emit('operation-end', {
31✔
1458
        id,
1459
        name,
1460
        attributes,
1461
        success: false,
1462
        error
1463
      })
1464
      throw error
31✔
1465
    }
1466
  }
1467

1468
  private scheduleGenerationCleanup(generation: number): void {
1469
    this.maintenance.scheduleGenerationCleanup(
2✔
1470
      generation,
1471
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1472
      (failedGeneration, error) => {
1473
        this.logger.warn?.('generation-cleanup-error', {
1✔
1474
          generation: failedGeneration,
1475
          error: this.formatError(error)
1476
        })
1477
      }
1478
    )
1479
  }
1480

1481
  private async cleanupGeneration(generation: number): Promise<void> {
1482
    const prefix = `v${generation}:`
3✔
1483
    const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix)
3✔
1484
    for (const batch of planGenerationCleanupBatches(keys, this.options.generationCleanup)) {
2✔
1485
      await this.deleteKeys(batch)
1✔
1486
      await this.publishInvalidation({
1✔
1487
        scope: 'keys',
1488
        keys: batch,
1489
        sourceId: this.instanceId,
1490
        operation: 'invalidate'
1491
      })
1492
    }
1493
  }
1494

1495
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1496
    this.maintenance.initializeWriteBehindTimer(
218✔
1497
      this.options.writeStrategy,
1498
      options,
1499
      this.flushWriteBehindQueue.bind(this)
1500
    )
1501
  }
1502

1503
  private shouldWriteBehind(layer: CacheLayer): boolean {
1504
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
171✔
1505
  }
1506

1507
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1508
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1509
  }
1510

1511
  private async flushWriteBehindQueue(): Promise<void> {
1512
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
26✔
1513
  }
1514

1515
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1516
    const results = await Promise.allSettled(batch.map((operation) => operation()))
4✔
1517
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4✔
1518
    if (failures.length === 0) {
3✔
1519
      return
2✔
1520
    }
1521

1522
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1523
    this.logger.error?.('write-behind-flush-failure', {
1✔
1524
      failed: failures.length,
1525
      total: batch.length,
1526
      errors: failures.map((failure) => this.formatError(failure.reason))
1✔
1527
    })
1528
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1529
  }
1530

1531
  private qualifyKey(key: string): string {
1532
    return qualifyGenerationKey(key, this.currentGeneration)
441✔
1533
  }
1534

1535
  private qualifyPattern(pattern: string): string {
1536
    return qualifyGenerationPattern(pattern, this.currentGeneration)
5✔
1537
  }
1538

1539
  private stripQualifiedKey(key: string): string {
1540
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1541
  }
1542

1543
  private validateConfiguration(): void {
1544
    if (
222✔
1545
      this.options.broadcastL1Invalidation !== undefined &&
226✔
1546
      this.options.publishSetInvalidation !== undefined &&
1547
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1548
    ) {
1549
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1550
    }
1551

1552
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
221✔
1553
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1554
    }
1555

1556
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
219✔
1557
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
219✔
1558
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
219✔
1559
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
219✔
1560
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
219✔
1561
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
219✔
1562
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
219✔
1563
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
219✔
1564
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
219✔
1565
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
219✔
1566
    if (this.options.snapshotMaxBytes !== false) {
219✔
1567
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
217✔
1568
    }
1569
    if (this.options.snapshotMaxEntries !== false) {
218✔
1570
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
217✔
1571
    }
1572
    if (this.options.invalidationMaxKeys !== false) {
218✔
1573
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
216✔
1574
    }
1575
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
218✔
1576
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
218✔
1577
    validateCircuitBreakerOptions(this.options.circuitBreaker)
218✔
1578
    if (typeof this.options.generationCleanup === 'object') {
218✔
1579
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
2✔
1580
    }
1581
    if (this.options.generation !== undefined) {
218✔
1582
      validateNonNegativeNumber('generation', this.options.generation)
5✔
1583
    }
1584
  }
1585

1586
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1587
    if (!options) {
407✔
1588
      return
286✔
1589
    }
1590

1591
    validateLayerNumberOption('options.ttl', options.ttl)
121✔
1592
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
121✔
1593
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
121✔
1594
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
121✔
1595
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
121✔
1596
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
121✔
1597
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
121✔
1598
    validateAdaptiveTtlOptions(options.adaptiveTtl)
121✔
1599
    validateCircuitBreakerOptions(options.circuitBreaker)
121✔
1600
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
121✔
1601
    validateTags(options.tags)
121✔
1602
  }
1603

1604
  private assertActive(operation: string): void {
1605
    if (this.isDisconnecting) {
907✔
1606
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1607
    }
1608
  }
1609

1610
  private async awaitStartup(operation: string): Promise<void> {
1611
    this.assertActive(operation)
439✔
1612
    await this.startup
439✔
1613
    this.assertActive(operation)
434✔
1614
  }
1615

1616
  private async applyFreshReadPolicies<T>(
1617
    key: string,
1618
    hit: Extract<ReadHit<T>, { found: true }>,
1619
    options: CacheGetOptions | undefined,
1620
    fetcher?: () => Promise<T>
1621
  ): Promise<void> {
1622
    const plan = planFreshReadPolicies({
69✔
1623
      stored: hit.stored,
1624
      hasFetcher: Boolean(fetcher),
1625
      slidingTtl: options?.slidingTtl ?? false,
133✔
1626
      refreshAheadSeconds:
1627
        this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0
69!
1628
    })
1629

1630
    if (plan.refreshedStored) {
69✔
1631
      for (let index = 0; index <= hit.layerIndex; index += 1) {
5✔
1632
        const layer = this.layers[index]
7✔
1633
        if (!layer || this.shouldSkipLayer(layer)) {
7✔
1634
          continue
1✔
1635
        }
1636

1637
        try {
6✔
1638
          await layer.set(key, plan.refreshedStored, plan.refreshedStoredTtl)
6✔
1639
        } catch (error) {
1640
          await this.handleLayerFailure(layer, 'sliding-ttl', error)
1✔
1641
        }
1642
      }
1643
    }
1644

1645
    if (fetcher && plan.shouldScheduleBackgroundRefresh) {
69✔
1646
      this.scheduleBackgroundRefresh(key, fetcher, options)
1✔
1647
    }
1648
  }
1649

1650
  private shouldSkipLayer(layer: CacheLayer): boolean {
1651
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
650✔
1652
    const skip = shouldSkipDegradedLayer(degradedUntil)
650✔
1653
    if (!skip && degradedUntil !== undefined) {
650!
NEW
1654
      this.layerDegradedUntil.delete(layer.name)
×
1655
    }
1656
    return skip
650✔
1657
  }
1658

1659
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1660
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
13✔
1661
    if (!recovery.degrade) {
13✔
1662
      throw error
4✔
1663
    }
1664

1665
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
9✔
1666
    this.metricsCollector.increment('degradedOperations')
9✔
1667
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
9✔
1668
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
13✔
1669
    return null
13✔
1670
  }
1671

1672
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1673
    if (this.isGracefulDegradationEnabled()) {
6✔
1674
      await this.handleLayerFailure(layer, operation, error)
4✔
1675
      return
4✔
1676
    }
1677

1678
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1679
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
6✔
1680
  }
1681

1682
  private isGracefulDegradationEnabled(): boolean {
1683
    return Boolean(this.options.gracefulDegradation)
8✔
1684
  }
1685

1686
  private recordCircuitFailure(key: string, options: CacheCircuitBreakerOptions | undefined, error: unknown): void {
1687
    if (!options) {
14✔
1688
      return
10✔
1689
    }
1690

1691
    this.circuitBreakerManager.recordFailure(key, options)
4✔
1692
    if (this.circuitBreakerManager.isOpen(key)) {
4!
1693
      this.metricsCollector.increment('circuitBreakerTrips')
4✔
1694
    }
1695
    this.emitError('fetch', { key, error: this.formatError(error) })
4✔
1696
  }
1697

1698
  private isNegativeStoredValue(stored: unknown): boolean {
1699
    return isStoredValueEnvelope(stored) && stored.kind === 'empty'
80✔
1700
  }
1701

1702
  private emitError(operation: string, context: Record<string, unknown>): void {
1703
    this.logger.error?.(operation, context)
20✔
1704
    if (this.listenerCount('error') > 0) {
20✔
1705
      this.emit('error', { operation, ...context })
5✔
1706
    }
1707
  }
1708

1709
  private snapshotMaxBytes(): number | false {
1710
    return this.options.snapshotMaxBytes === false
10✔
1711
      ? false
1712
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
17✔
1713
  }
1714

1715
  private snapshotMaxEntries(): number | false {
1716
    return this.options.snapshotMaxEntries === false
8✔
1717
      ? false
1718
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
13✔
1719
  }
1720

1721
  private invalidationMaxKeys(): number | false {
1722
    return this.options.invalidationMaxKeys === false
32✔
1723
      ? false
1724
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
55✔
1725
  }
1726
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc