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

flyingsquirrel0419 / layercache / 25206048603

01 May 2026 07:08AM UTC coverage: 95.607% (-0.1%) from 95.722%
25206048603

Pull #33

github

web-flow
Merge 64da5649a into 681953ebf
Pull Request #33: feat: add context-aware cache entry options

1611 of 1732 branches covered (93.01%)

Branch coverage included in aggregate %.

29 of 33 new or added lines in 2 files covered. (87.88%)

25 existing lines in 1 file now uncovered.

2916 of 3003 relevant lines covered (97.1%)

332.82 hits per line

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

92.65
/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 CacheEntryWriteKind,
54
  type CacheEntryWriteOptions,
55
  type CacheGetOptions,
56
  type CacheHealthCheckResult,
57
  type CacheHitRateSnapshot,
58
  type CacheInspectResult,
59
  type CacheLayer,
60
  type CacheLayerSetManyEntry,
61
  type CacheLogger,
62
  type CacheMGetEntry,
63
  type CacheMSetEntry,
64
  type CacheMetricsSnapshot,
65
  CacheMissError,
66
  type CacheSnapshotEntry,
67
  type CacheStackEvents,
68
  type CacheStackOptions,
69
  type CacheStatsSnapshot,
70
  type CacheTagIndex,
71
  type CacheTtlPolicy,
72
  type CacheWarmEntry,
73
  type CacheWarmOptions,
74
  type CacheWarmProgress,
75
  type CacheWrapOptions,
76
  type CacheWriteBehindOptions,
77
  type CacheWriteOptions,
78
  type InvalidationMessage,
79
  type LayerTtlMap
80
} from './types'
81

82
const DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1_024 * 1_024
11✔
83
const DEFAULT_SNAPSHOT_MAX_ENTRIES = 10_000
11✔
84
const DEFAULT_INVALIDATION_MAX_KEYS = 10_000
11✔
85
const DEFAULT_MAX_PROFILE_ENTRIES = 100_000
11✔
86

87
class DebugLogger implements CacheLogger {
88
  private readonly enabled: boolean
89

90
  constructor(enabled: boolean) {
91
    this.enabled = enabled
218✔
92
  }
93

94
  debug(message: string, context?: Record<string, unknown>): void {
95
    this.write('debug', message, context)
596✔
96
  }
97

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

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

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

110
  private write(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
111
    if (!this.enabled) {
664✔
112
      return
662✔
113
    }
114

115
    const suffix = context ? ` ${JSON.stringify(context)}` : ''
2✔
116
    console[level](`[layercache] ${message}${suffix}`)
664✔
117
  }
118
}
119

120
/** Typed overloads for EventEmitter so callers get autocomplete on event names. */
121
export interface CacheStack {
122
  on<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
123
  once<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
124
  off<K extends keyof CacheStackEvents>(event: K, listener: (data: CacheStackEvents[K]) => void): this
125
  removeAllListeners<K extends keyof CacheStackEvents>(event?: K): this
126
  listeners<K extends keyof CacheStackEvents>(event: K): Array<(data: CacheStackEvents[K]) => void>
127
  listenerCount<K extends keyof CacheStackEvents>(event: K): number
128
  emit<K extends keyof CacheStackEvents>(event: K, data: CacheStackEvents[K]): boolean
129
}
130

131
export class CacheStack extends EventEmitter {
132
  private readonly stampedeGuard: StampedeGuard
133
  private readonly metricsCollector = new MetricsCollector()
230✔
134
  private readonly instanceId = createInstanceId()
230✔
135
  private readonly startup: Promise<void>
136
  private unsubscribeInvalidation?: () => Promise<void> | void
137
  private readonly logger: CacheLogger
138
  private readonly tagIndex: CacheTagIndex
139
  private readonly keyDiscovery: CacheKeyDiscovery
140
  private readonly fetchRateLimiter = new FetchRateLimiter()
230✔
141
  private readonly snapshotSerializer = new JsonSerializer()
230✔
142
  private readonly invalidation: CacheStackInvalidationSupport
143
  private readonly layerWriter: CacheStackLayerWriter
144
  private readonly snapshots: CacheStackSnapshotManager
145
  private readonly layerDegradedUntil = new Map<string, number>()
230✔
146
  private readonly maintenance = new CacheStackMaintenance()
230✔
147
  private readonly ttlResolver: TtlResolver
148
  private readonly circuitBreakerManager: CircuitBreakerManager
149
  private nextOperationId = 0
230✔
150
  private currentGeneration?: number
151
  private isDisconnecting = false
230✔
152
  private readonly reader: CacheStackReader
153
  private disconnectPromise?: Promise<void>
154

155
  constructor(
156
    private readonly layers: CacheLayer[],
230✔
157
    private readonly options: CacheStackOptions = {}
230✔
158
  ) {
159
    super()
230✔
160

161
    if (layers.length === 0) {
230✔
162
      throw new Error('CacheStack requires at least one cache layer.')
1✔
163
    }
164

165
    this.validateConfiguration()
229✔
166

167
    const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES
229✔
168
    this.ttlResolver = new TtlResolver({ maxProfileEntries })
230✔
169
    this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries })
230✔
170
    this.stampedeGuard = new StampedeGuard({
230✔
171
      maxInFlight: options.stampedeMaxInFlight,
172
      entryTimeoutMs: options.stampedeEntryTimeoutMs
173
    })
174
    this.currentGeneration = options.generation
230✔
175

176
    if (options.publishSetInvalidation !== undefined) {
230✔
177
      console.warn(
1✔
178
        '[layercache] CacheStackOptions.publishSetInvalidation is deprecated. ' + 'Use broadcastL1Invalidation instead.'
179
      )
180
    }
181

182
    const debugEnv = process.env.DEBUG?.split(',').includes('layercache:debug') ?? false
225✔
183
    this.logger =
230✔
184
      typeof options.logger === 'object' ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv)
659✔
185
    this.tagIndex = options.tagIndex ?? new TagIndex()
230✔
186
    this.keyDiscovery = new CacheKeyDiscovery({
230✔
187
      layers: this.layers,
188
      tagIndex: this.tagIndex,
189
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
16✔
190
      handleLayerFailure: async (layer, operation, error) => {
191
        await this.handleLayerFailure(layer, operation, error)
1✔
192
      }
193
    })
194
    this.invalidation = new CacheStackInvalidationSupport({
230✔
195
      tagIndex: this.tagIndex,
196
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
34✔
197
      handleLayerFailure: async (layer, operation, error) => {
UNCOV
198
        await this.handleLayerFailure(layer, operation, error)
×
199
      }
200
    })
201
    this.layerWriter = new CacheStackLayerWriter({
230✔
202
      layers: this.layers,
203
      maintenance: this.maintenance,
204
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
183✔
205
      shouldWriteBehind: (layer) => this.shouldWriteBehind(layer),
174✔
206
      handleLayerFailure: async (layer, operation, error) => {
207
        await this.handleLayerFailure(layer, operation, error)
4✔
208
      },
209
      enqueueWriteBehind: this.enqueueWriteBehind.bind(this),
210
      resolveFreshTtl: this.resolveFreshTtl.bind(this),
211
      resolveLayerSeconds: this.resolveLayerSeconds.bind(this),
212
      globalStaleWhileRevalidate: this.options.staleWhileRevalidate,
213
      globalStaleIfError: this.options.staleIfError,
214
      writePolicy: this.options.writePolicy,
215
      onWriteFailures: (context, failures) => {
216
        this.metricsCollector.increment('writeFailures', failures.length)
3✔
217
        this.logger.debug?.('write-failure', {
3✔
218
          ...context,
219
          failures: failures.map((failure) => this.formatError(failure))
3✔
220
        })
221
      }
222
    })
223
    if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
255✔
224
      this.logger.warn?.(
21✔
225
        '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.'
226
      )
227
    }
228
    if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
256✔
229
      this.logger.warn?.(
4✔
230
        '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.'
231
      )
232
    }
233
    if (
225✔
234
      options.invalidationBus &&
254✔
235
      options.broadcastL1Invalidation === undefined &&
236
      options.publishSetInvalidation === undefined
237
    ) {
238
      this.logger.warn?.(
12✔
239
        'broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired.'
240
      )
241
    }
242
    this.snapshots = new CacheStackSnapshotManager({
225✔
243
      layers: this.layers,
244
      tagIndex: this.tagIndex,
245
      snapshotSerializer: this.snapshotSerializer,
246
      readLayerEntry: (layer, key) => this.reader.readLayerEntry(layer, key),
4✔
247
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
3✔
UNCOV
248
      handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
×
249
      qualifyKey: this.qualifyKey.bind(this),
250
      stripQualifiedKey: this.stripQualifiedKey.bind(this),
251
      validateCacheKey,
252
      formatError: this.formatError.bind(this)
253
    })
254
    this.reader = new CacheStackReader({
225✔
255
      layers: this.layers,
256
      metricsCollector: this.metricsCollector,
257
      maintenance: this.maintenance,
258
      tagIndex: this.tagIndex,
259
      circuitBreakerManager: this.circuitBreakerManager,
260
      fetchRateLimiter: this.fetchRateLimiter,
261
      stampedeGuard: this.stampedeGuard,
262
      ttlResolver: this.ttlResolver,
263
      logger: this.logger,
264
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
392✔
265
      handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
5✔
266
      emit: (event, data) => this.emit(event, data as never),
366✔
267
      emitError: (operation, context) => this.emitError(operation, context),
1✔
268
      formatError: (error) => this.formatError(error),
9✔
269
      storeEntry: (key, kind, value, options) => this.storeEntry(key, kind, value, options),
47✔
270
      recordCircuitFailure: (key, options, error) => this.recordCircuitFailure(key, options, error),
12✔
271
      resolveLayerSeconds: (layerName, override, globalDefault, fallback) =>
272
        this.resolveLayerSeconds(layerName, override, globalDefault, fallback),
70✔
273
      sleep: (ms) => this.sleep(ms),
5✔
274
      withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
9✔
275
      isDisconnecting: () => this.isDisconnecting,
10✔
276
      isGracefulDegradationEnabled: () => this.isGracefulDegradationEnabled(),
2✔
277
      scheduleBackgroundRefreshDispatch: <T>(key: string, fetcher: () => Promise<T>, options?: CacheGetOptions) =>
278
        this.scheduleBackgroundRefresh(key, fetcher, options),
1✔
279
      stampedePrevention: options.stampedePrevention,
280
      singleFlightCoordinator: options.singleFlightCoordinator,
281
      singleFlightLeaseMs: options.singleFlightLeaseMs,
282
      singleFlightTimeoutMs: options.singleFlightTimeoutMs,
283
      singleFlightPollMs: options.singleFlightPollMs,
284
      singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
285
      backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
286
      negativeCaching: options.negativeCaching,
287
      refreshAhead: options.refreshAhead,
288
      circuitBreaker: options.circuitBreaker,
289
      fetcherRateLimit: options.fetcherRateLimit
290
    })
291
    this.initializeWriteBehind(options.writeBehind)
225✔
292
    this.startup = this.initialize()
225✔
293
  }
294

295
  /**
296
   * Read-through cache get.
297
   * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
298
   * and stores the result across all layers. Returns `null` if the key is not found
299
   * and no `fetcher` is provided.
300
   */
301
  async get<T>(key: string, fetcher?: () => Promise<T>, options?: CacheGetOptions): Promise<T | null> {
302
    return this.observeOperation('layercache.get', { 'layercache.key': String(key ?? '') }, async () => {
276✔
303
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
276✔
304
      this.validateWriteOptions(options)
276✔
305
      await this.awaitStartup('get')
276✔
306
      return this.reader.getPrepared(normalizedKey, fetcher, options)
270✔
307
    })
308
  }
309

310
  /**
311
   * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
312
   * Fetches and caches the value if not already present.
313
   */
314
  async getOrSet<T>(key: string, fetcher: () => Promise<T>, options?: CacheGetOptions): Promise<T | null> {
315
    return this.get(key, fetcher, options)
3✔
316
  }
317

318
  /**
319
   * Like `get()`, but throws `CacheMissError` instead of returning `null`.
320
   * Useful when the value is expected to exist or the fetcher is expected to
321
   * return non-null.
322
   */
323
  async getOrThrow<T>(key: string, fetcher?: () => Promise<T>, options?: CacheGetOptions): Promise<T> {
324
    const value = await this.get(key, fetcher, options)
4✔
325
    if (value === null) {
4✔
326
      throw new CacheMissError(key)
3✔
327
    }
328
    return value
1✔
329
  }
330

331
  /**
332
   * Returns true if the given key exists and is not expired in any layer.
333
   */
334
  async has(key: string): Promise<boolean> {
335
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
8✔
336
    await this.awaitStartup('has')
8✔
337

338
    for (const layer of this.layers) {
8✔
339
      if (this.shouldSkipLayer(layer)) {
15!
UNCOV
340
        continue
×
341
      }
342
      if (layer.has) {
15✔
343
        try {
5✔
344
          const exists = await layer.has(normalizedKey)
5✔
345
          if (exists) {
4✔
346
            return true
2✔
347
          }
348
        } catch {
349
          await this.reportRecoverableLayerFailure(layer, 'has', new Error(`has() failed for layer "${layer.name}"`))
1✔
350
          // fall through to next layer
351
        }
352
      } else {
353
        try {
10✔
354
          const value = await layer.get(normalizedKey)
10✔
355
          if (value !== null) {
7✔
356
            return true
2✔
357
          }
358
        } catch (error) {
359
          await this.reportRecoverableLayerFailure(layer, 'has', error)
3✔
360
          // fall through
361
        }
362
      }
363
    }
364
    return false
4✔
365
  }
366

367
  /**
368
   * Returns the remaining TTL in seconds for the key in the fastest layer
369
   * that has it, or null if the key is not found / has no TTL.
370
   */
371
  async ttl(key: string): Promise<number | null> {
372
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
4✔
373
    await this.awaitStartup('ttl')
4✔
374

375
    for (const layer of this.layers) {
4✔
376
      if (this.shouldSkipLayer(layer)) {
8✔
377
        continue
1✔
378
      }
379
      if (layer.ttl) {
7✔
380
        try {
6✔
381
          const remaining = await layer.ttl(normalizedKey)
6✔
382
          if (remaining !== null) {
4✔
383
            return remaining
3✔
384
          }
385
        } catch {
386
          // fall through
387
        }
388
      }
389
    }
390
    return null
1✔
391
  }
392

393
  /**
394
   * Stores a value in all cache layers. Overwrites any existing value.
395
   */
396
  async set<T>(key: string, value: T, options?: CacheWriteOptions): Promise<void> {
397
    await this.observeOperation('layercache.set', { 'layercache.key': String(key ?? '') }, async () => {
102✔
398
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
102✔
399
      this.validateWriteOptions(options)
102✔
400
      await this.awaitStartup('set')
102✔
401
      await this.storeEntry(normalizedKey, 'value', value, options)
98✔
402
    })
403
  }
404

405
  /**
406
   * Deletes the key from all layers and publishes an invalidation message.
407
   */
408
  async delete(key: string): Promise<void> {
409
    await this.observeOperation('layercache.delete', { 'layercache.key': String(key ?? '') }, async () => {
7✔
410
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
7✔
411
      await this.awaitStartup('delete')
7✔
412
      await this.deleteKeys([normalizedKey])
6✔
413
      await this.publishInvalidation({
6✔
414
        scope: 'key',
415
        keys: [normalizedKey],
416
        sourceId: this.instanceId,
417
        operation: 'delete'
418
      })
419
    })
420
  }
421

422
  async clear(): Promise<void> {
423
    await this.awaitStartup('clear')
4✔
424
    this.maintenance.beginClearEpoch()
4✔
425
    await Promise.all(this.layers.map((layer) => layer.clear()))
5✔
426
    await this.tagIndex.clear()
4✔
427
    this.ttlResolver.clearProfiles()
4✔
428
    this.circuitBreakerManager.clear()
4✔
429
    this.metricsCollector.increment('invalidations')
4✔
430
    this.logger.debug?.('clear')
4✔
431
    await this.publishInvalidation({ scope: 'clear', sourceId: this.instanceId, operation: 'clear' })
4✔
432
  }
433

434
  /**
435
   * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
436
   */
437
  async mdelete(keys: string[]): Promise<void> {
438
    if (keys.length === 0) {
3✔
439
      return
1✔
440
    }
441
    await this.awaitStartup('mdelete')
2✔
442
    const normalizedKeys = keys.map((k) => validateCacheKey(k))
3✔
443
    const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key))
3✔
444
    await this.deleteKeys(cacheKeys)
2✔
445
    await this.publishInvalidation({
2✔
446
      scope: 'keys',
447
      keys: cacheKeys,
448
      sourceId: this.instanceId,
449
      operation: 'delete'
450
    })
451
  }
452

453
  async mget<T>(entries: CacheMGetEntry<T>[]): Promise<Array<T | null>> {
454
    return this.observeOperation('layercache.mget', undefined, async () => {
9✔
455
      this.assertActive('mget')
9✔
456
      if (entries.length === 0) {
9✔
457
        return []
1✔
458
      }
459

460
      const normalizedEntries = entries.map((entry) => ({
18✔
461
        ...entry,
462
        key: this.qualifyKey(validateCacheKey(entry.key))
463
      }))
464
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
18✔
465
      const canFastPath = normalizedEntries.every((entry) => entry.fetch === undefined && entry.options === undefined)
16✔
466
      if (!canFastPath) {
8✔
467
        await this.awaitStartup('mget')
2✔
468
        const pendingReads = new Map<
2✔
469
          string,
470
          {
471
            promise: Promise<T | null>
472
            fetch?: () => Promise<T>
473
            optionsSignature: string
474
          }
475
        >()
476

477
        return Promise.all(
2✔
478
          normalizedEntries.map((entry) => {
479
            const optionsSignature = serializeOptions(entry.options)
4✔
480
            const existing = pendingReads.get(entry.key)
4✔
481
            if (!existing) {
4✔
482
              const promise = this.reader.getPrepared(entry.key, entry.fetch, entry.options)
2✔
483
              pendingReads.set(entry.key, {
2✔
484
                promise,
485
                fetch: entry.fetch,
486
                optionsSignature
487
              })
488
              return promise
2✔
489
            }
490

491
            if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2!
492
              const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key
2!
493
              throw new Error(`mget received conflicting entries for key "${displayKey}".`)
2✔
494
            }
495

UNCOV
496
            return existing.promise
×
497
          })
498
        )
499
      }
500

501
      await this.awaitStartup('mget')
6✔
502
      const pending = new Set<string>()
6✔
503
      const indexesByKey = new Map<string, number[]>()
6✔
504
      const resultsByKey = new Map<string, T | null>()
6✔
505

506
      for (let index = 0; index < normalizedEntries.length; index += 1) {
6✔
507
        const entry = normalizedEntries[index]
14✔
508
        if (!entry) continue
14!
509
        const key = entry.key
14✔
510
        const indexes = indexesByKey.get(key) ?? []
14✔
511
        indexes.push(index)
14✔
512
        indexesByKey.set(key, indexes)
14✔
513
        pending.add(key)
14✔
514
      }
515

516
      for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
6✔
517
        const layer = this.layers[layerIndex]
6✔
518
        if (!layer || this.shouldSkipLayer(layer)) continue
6!
519
        const keys = [...pending]
6✔
520
        if (keys.length === 0) {
6!
UNCOV
521
          break
×
522
        }
523

524
        const values = layer.getMany
6!
525
          ? await layer.getMany(keys)
UNCOV
526
          : await Promise.all(keys.map((key) => this.reader.readLayerEntry(layer, key)))
×
527

UNCOV
528
        for (let offset = 0; offset < values.length; offset += 1) {
×
529
          const key = keys[offset]
13✔
530
          const stored = values[offset]
13✔
531
          if (!key || stored === null) {
13✔
532
            continue
2✔
533
          }
534

535
          const resolved = resolveStoredValue<T>(stored)
11✔
536
          if (resolved.state === 'expired') {
11✔
537
            await layer.delete(key)
1✔
538
            continue
1✔
539
          }
540

541
          if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
10!
UNCOV
542
            this.metricsCollector.increment('staleHits', indexesByKey.get(key)?.length ?? 1)
×
543
          }
544

545
          await this.tagIndex.touch(key)
10✔
546
          await this.reader.backfill(key, stored, layerIndex - 1)
10✔
547
          resultsByKey.set(key, resolved.value)
10✔
548
          pending.delete(key)
10✔
549
          this.metricsCollector.increment('hits', indexesByKey.get(key)?.length ?? 1)
10!
550
        }
551
      }
552

553
      if (pending.size > 0) {
6✔
554
        for (const key of pending) {
2✔
555
          await this.tagIndex.remove(key)
3✔
556
          this.metricsCollector.increment('misses', indexesByKey.get(key)?.length ?? 1)
3!
557
        }
558
      }
559

560
      return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null)
14✔
561
    })
562
  }
563

564
  async mset<T>(entries: CacheMSetEntry<T>[]): Promise<void> {
565
    await this.observeOperation('layercache.mset', undefined, async () => {
10✔
566
      this.assertActive('mset')
10✔
567
      const normalizedEntries = entries.map((entry) => ({
20✔
568
        ...entry,
569
        key: this.qualifyKey(validateCacheKey(entry.key))
570
      }))
571
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
20✔
572
      await this.awaitStartup('mset')
10✔
573
      await this.writeBatch(normalizedEntries)
10✔
574
    })
575
  }
576

577
  async warm(entries: CacheWarmEntry[], options: CacheWarmOptions = {}): Promise<void> {
4✔
578
    this.assertActive('warm')
4✔
579
    const concurrency = Math.max(1, options.concurrency ?? 4)
4✔
580
    const total = entries.length
4✔
581
    let completed = 0
4✔
582
    const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
4!
583
    const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
4!
584
      while (queue.length > 0) {
4✔
585
        const entry = queue.shift()
6✔
586
        if (!entry) {
6!
UNCOV
587
          return
×
588
        }
589

590
        let success = false
6✔
591
        try {
6✔
592
          await this.get(entry.key, entry.fetcher, entry.options)
6✔
593
          this.emit('warm', { key: entry.key })
4✔
594
          success = true
4✔
595
        } catch (error) {
596
          this.emitError('warm', { key: entry.key, error: this.formatError(error) })
2✔
597
          if (!options.continueOnError) {
2✔
598
            throw error
1✔
599
          }
600
        } finally {
601
          completed += 1
6✔
602
          const progress: CacheWarmProgress = { completed, total, key: entry.key, success }
6✔
603
          options.onProgress?.(progress)
6✔
604
        }
605
      }
606
    })
607

608
    await Promise.all(workers)
4✔
609
  }
610

611
  /**
612
   * Returns a cached version of `fetcher`. The cache key is derived from
613
   * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
614
   */
615
  wrap<TArgs extends unknown[], TResult>(
616
    prefix: string,
617
    fetcher: (...args: TArgs) => Promise<TResult>,
618
    options: CacheWrapOptions<TArgs> = {}
9✔
619
  ): (...args: TArgs) => Promise<TResult | null> {
620
    return (...args: TArgs) => {
9✔
621
      const suffix = options.keyResolver
16✔
622
        ? options.keyResolver(...args)
623
        : args.map((argument) => serializeKeyPart(argument)).join(':')
9✔
624
      const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix
16!
625
      return this.get<TResult>(key, () => fetcher(...args), options)
16✔
626
    }
627
  }
628

629
  /**
630
   * Creates a `CacheNamespace` that automatically prefixes all keys with
631
   * `prefix:`. Useful for multi-tenant or module-level isolation.
632
   */
633
  namespace(prefix: string): CacheNamespace {
634
    validateNamespaceKey(prefix)
36✔
635
    return new CacheNamespace(this, prefix)
36✔
636
  }
637

638
  async invalidateByTag(tag: string): Promise<void> {
639
    await this.observeOperation('layercache.invalidate_by_tag', undefined, async () => {
8✔
640
      validateTag(tag)
8✔
641
      await this.awaitStartup('invalidateByTag')
8✔
642
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
7✔
643
      await this.deleteKeys(keys)
6✔
644
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
645
    })
646
  }
647

648
  async invalidateByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
4✔
649
    await this.observeOperation('layercache.invalidate_by_tags', undefined, async () => {
4✔
650
      if (tags.length === 0) {
4!
UNCOV
651
        return
×
652
      }
653

654
      validateTags(tags)
4✔
655
      await this.awaitStartup('invalidateByTags')
4✔
656
      const keysByTag = await Promise.all(
4✔
657
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
7✔
658
      )
659
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
3✔
660
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
4✔
661

662
      await this.deleteKeys(keys)
4✔
663
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
664
    })
665
  }
666

667
  async invalidateByPattern(pattern: string): Promise<void> {
668
    await this.observeOperation('layercache.invalidate_by_pattern', undefined, async () => {
5✔
669
      validatePattern(pattern)
5✔
670
      await this.awaitStartup('invalidateByPattern')
5✔
671
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
5✔
672
        this.qualifyPattern(pattern),
673
        this.invalidationMaxKeys()
674
      )
675
      await this.deleteKeys(keys)
4✔
676
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
4✔
677
    })
678
  }
679

680
  async invalidateByPrefix(prefix: string): Promise<void> {
681
    await this.observeOperation('layercache.invalidate_by_prefix', undefined, async () => {
7✔
682
      await this.awaitStartup('invalidateByPrefix')
7✔
683
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
7✔
684
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
7✔
685
      await this.deleteKeys(keys)
6✔
686
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
687
    })
688
  }
689

690
  getMetrics(): CacheMetricsSnapshot {
691
    return this.metricsCollector.snapshot
189✔
692
  }
693

694
  getStats(): CacheStatsSnapshot {
695
    return {
29✔
696
      metrics: this.getMetrics(),
697
      layers: this.layers.map((layer) => ({
33✔
698
        name: layer.name,
699
        isLocal: Boolean(layer.isLocal),
700
        degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
62✔
701
      })),
702
      backgroundRefreshes: this.reader.activeRefreshCount
703
    }
704
  }
705

706
  resetMetrics(): void {
UNCOV
707
    this.metricsCollector.reset()
×
708
  }
709

710
  /**
711
   * Returns computed hit-rate statistics (overall and per-layer).
712
   */
713
  getHitRate(): CacheHitRateSnapshot {
714
    return this.metricsCollector.hitRate()
6✔
715
  }
716

717
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
718
    await this.startup
2✔
719

720
    return Promise.all(
2✔
721
      this.layers.map(async (layer) => {
722
        const startedAt = performance.now()
4✔
723
        try {
4✔
724
          const healthy = layer.ping ? await layer.ping() : true
4✔
725
          return {
4✔
726
            layer: layer.name,
727
            healthy,
728
            latencyMs: performance.now() - startedAt
729
          }
730
        } catch (error) {
731
          return {
1✔
732
            layer: layer.name,
733
            healthy: false,
734
            latencyMs: performance.now() - startedAt,
735
            error: this.formatError(error)
736
          }
737
        }
738
      })
739
    )
740
  }
741

742
  /**
743
   * Rotates the active generation prefix used for all future cache keys.
744
   * Previous-generation keys remain in the underlying layers until they expire,
745
   * unless `generationCleanup` is enabled to prune them in the background.
746
   */
747
  bumpGeneration(nextGeneration?: number): number {
748
    const current = this.currentGeneration ?? 0
3!
749
    const previousGeneration = this.currentGeneration
3✔
750
    const updatedGeneration = nextGeneration ?? current + 1
3✔
751
    const generationToCleanup = resolveGenerationCleanupTarget({
3✔
752
      previousGeneration,
753
      nextGeneration: updatedGeneration,
754
      generationCleanup: this.options.generationCleanup
755
    })
756

757
    this.currentGeneration = updatedGeneration
3✔
758
    if (generationToCleanup !== null) {
3✔
759
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
760
    }
761

762
    return this.currentGeneration
3✔
763
  }
764

765
  /**
766
   * Returns detailed metadata about a single cache key: which layers contain it,
767
   * remaining fresh/stale/error TTLs, and associated tags.
768
   * Returns `null` if the key does not exist in any layer.
769
   */
770
  async inspect(key: string): Promise<CacheInspectResult | null> {
771
    const userKey = validateCacheKey(key)
4✔
772
    const normalizedKey = this.qualifyKey(userKey)
4✔
773
    await this.awaitStartup('inspect')
4✔
774

775
    const foundInLayers: string[] = []
4✔
776
    let freshTtlSeconds: number | null = null
4✔
777
    let staleTtlSeconds: number | null = null
4✔
778
    let errorTtlSeconds: number | null = null
4✔
779
    let isStale = false
4✔
780

781
    for (const layer of this.layers) {
4✔
782
      if (this.shouldSkipLayer(layer)) {
4!
UNCOV
783
        continue
×
784
      }
785
      const stored = await this.readLayerEntry(layer, normalizedKey)
4✔
786
      if (stored === null) {
4✔
787
        continue
1✔
788
      }
789

790
      const resolved = resolveStoredValue(stored)
3✔
791
      if (resolved.state === 'expired') {
3!
UNCOV
792
        continue
×
793
      }
794

795
      foundInLayers.push(layer.name)
3✔
796

797
      // Take TTL info from the first (fastest) layer that has it
798
      if (foundInLayers.length === 1 && resolved.envelope) {
3!
799
        const now = Date.now()
3✔
800
        freshTtlSeconds =
3✔
801
          resolved.envelope.freshUntil !== null
3!
802
            ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1_000))
803
            : null
804
        staleTtlSeconds =
3✔
805
          resolved.envelope.staleUntil !== null
3✔
806
            ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1_000))
807
            : null
808
        errorTtlSeconds =
3✔
809
          resolved.envelope.errorUntil !== null
3✔
810
            ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1_000))
811
            : null
812
        isStale = resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error'
3✔
813
      }
814
    }
815

816
    if (foundInLayers.length === 0) {
4✔
817
      return null
1✔
818
    }
819

820
    const tags = await this.getTagsForKey(normalizedKey)
3✔
821

822
    return { key: userKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags }
3✔
823
  }
824

825
  async exportState(): Promise<CacheSnapshotEntry[]> {
826
    await this.awaitStartup('exportState')
2✔
827
    return this.snapshots.exportState(this.snapshotMaxEntries())
2✔
828
  }
829

830
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
831
    await this.awaitStartup('importState')
1✔
832
    await this.snapshots.importState(entries)
1✔
833
  }
834

835
  async persistToFile(filePath: string): Promise<void> {
836
    this.assertActive('persistToFile')
4✔
837
    await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries())
4✔
838
  }
839

840
  async restoreFromFile(filePath: string): Promise<void> {
841
    this.assertActive('restoreFromFile')
8✔
842
    await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes())
8✔
843
  }
844

845
  async disconnect(): Promise<void> {
846
    if (!this.disconnectPromise) {
26!
847
      this.isDisconnecting = true
26✔
848
      this.disconnectPromise = (async () => {
26✔
849
        await this.startup
26✔
850
        await this.unsubscribeInvalidation?.()
26✔
851
        await this.flushWriteBehindQueue()
26✔
852
        await this.maintenance.waitForGenerationCleanup()
26✔
853
        this.reader.abortAllRefreshes()
26✔
854
        await Promise.allSettled(
26✔
855
          this.reader.getAllRefreshPromises().map((promise) => {
856
            let timer: ReturnType<typeof setTimeout> | undefined
UNCOV
857
            return Promise.race([
×
858
              promise,
859
              new Promise<void>((resolve) => {
UNCOV
860
                timer = setTimeout(resolve, 5_000)
×
UNCOV
861
                timer.unref?.()
×
862
              })
863
            ]).finally(() => {
UNCOV
864
              if (timer) clearTimeout(timer)
×
865
            })
866
          })
867
        )
868
        this.maintenance.disposeWriteBehindTimer()
26✔
869
        this.fetchRateLimiter.dispose()
26✔
870
        await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()))
38✔
871
      })()
872
    }
873

874
    await this.disconnectPromise
26✔
875
  }
876

877
  private async initialize(): Promise<void> {
878
    if (!this.options.invalidationBus) {
225✔
879
      return
209✔
880
    }
881

882
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
16✔
883
      await this.handleInvalidationMessage(message)
10✔
884
    })
885
  }
886

887
  private async storeEntry(
888
    key: string,
889
    kind: CacheWriteKind,
890
    value: unknown,
891
    options?: CacheWriteOptions
892
  ): Promise<void> {
893
    const resolvedOptions = this.resolveContextOptions(key, kind, value, options)
145✔
894
    const clearEpoch = this.maintenance.currentClearEpoch()
145✔
895
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
145✔
896
    await this.layerWriter.writeAcrossLayers(key, kind, value, resolvedOptions)
145✔
897
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
144!
UNCOV
898
      return
×
899
    }
900
    if (resolvedOptions?.tags) {
144✔
901
      await this.tagIndex.track(key, resolvedOptions.tags)
21✔
902
    } else {
903
      await this.tagIndex.touch(key)
123✔
904
    }
905

906
    this.metricsCollector.increment('sets')
144✔
907
    this.logger.debug?.('set', { key, kind, tags: resolvedOptions?.tags })
144✔
908
    this.emit('set', { key, kind: kind as string, tags: resolvedOptions?.tags })
145✔
909
    if (this.shouldBroadcastL1Invalidation()) {
145✔
910
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
911
    }
912
  }
913

914
  private async writeBatch(
915
    entries: Array<{ key: string; value: unknown; options?: CacheWriteOptions }>
916
  ): Promise<void> {
917
    const resolvedEntries = entries.map((entry) => ({
20✔
918
      ...entry,
919
      options: this.resolveContextOptions(entry.key, 'value', entry.value, entry.options)
920
    }))
921
    const { clearEpoch, entryEpochs } = await this.layerWriter.writeBatch(resolvedEntries)
10✔
922
    if (clearEpoch !== this.maintenance.currentClearEpoch()) {
9!
UNCOV
923
      return
×
924
    }
925

926
    for (const entry of resolvedEntries) {
9✔
927
      if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
18!
UNCOV
928
        continue
×
929
      }
930
      if (entry.options?.tags) {
18!
UNCOV
931
        await this.tagIndex.track(entry.key, entry.options.tags)
×
932
      } else {
933
        await this.tagIndex.touch(entry.key)
18✔
934
      }
935

936
      this.metricsCollector.increment('sets')
18✔
937
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
18✔
938
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
18✔
939
    }
940

941
    if (this.shouldBroadcastL1Invalidation()) {
9✔
942
      await this.publishInvalidation({
1✔
943
        scope: 'keys',
944
        keys: entries.map((entry) => entry.key),
2✔
945
        sourceId: this.instanceId,
946
        operation: 'write'
947
      })
948
    }
949
  }
950

951
  private resolveFreshTtl(
952
    key: string,
953
    layerName: string,
954
    kind: CacheWriteKind,
955
    options: CacheWriteOptions | undefined,
956
    fallbackTtl: number | undefined,
957
    value: unknown
958
  ): number | undefined {
959
    return this.ttlResolver.resolveFreshTtl(
183✔
960
      key,
961
      layerName,
962
      kind,
963
      options,
964
      fallbackTtl,
965
      this.options.negativeTtl,
966
      undefined,
967
      value
968
    )
969
  }
970

971
  private resolveLayerSeconds(
972
    layerName: string,
973
    override: number | LayerTtlMap | undefined,
974
    globalDefault?: number | LayerTtlMap,
975
    fallback?: number
976
  ): number | undefined {
977
    return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback)
436✔
978
  }
979

980
  private resolveContextOptions(
981
    key: string,
982
    kind: CacheEntryWriteKind,
983
    value: unknown,
984
    options: CacheWriteOptions | undefined
985
  ): CacheWriteOptions | undefined {
986
    if (!options?.contextOptions) {
165✔
987
      return options
162✔
988
    }
989

990
    const { contextOptions, ...baseOptions } = options
3✔
991
    let overrides: CacheEntryWriteOptions | undefined
992
    try {
3✔
993
      overrides = contextOptions({ key, value, kind } as CacheContextOptionsContext)
3✔
994
    } catch (error) {
NEW
UNCOV
995
      throw new Error(`options.contextOptions() failed for key "${key}": ${this.formatError(error)}`)
×
996
    }
997
    if (!overrides) {
3!
NEW
UNCOV
998
      return baseOptions
×
999
    }
1000

1001
    try {
3✔
1002
      validateContextEntryOptions('options.contextOptions()', overrides)
3✔
1003
    } catch (error) {
1004
      throw new Error(
1✔
1005
        `options.contextOptions() returned invalid entry options for key "${key}": ${this.formatError(error)}`
1006
      )
1007
    }
1008
    return {
2✔
1009
      ...baseOptions,
1010
      ...overrides
1011
    }
1012
  }
1013

1014
  private async deleteKeys(keys: string[]): Promise<void> {
1015
    if (keys.length === 0) {
28✔
1016
      return
4✔
1017
    }
1018

1019
    this.maintenance.bumpKeyEpochs(keys)
24✔
1020
    await this.invalidation.deleteKeysFromLayers(this.layers, keys)
24✔
1021

1022
    for (const key of keys) {
24✔
1023
      await this.tagIndex.remove(key)
31✔
1024
      this.ttlResolver.deleteProfile(key)
31✔
1025
      this.circuitBreakerManager.delete(key)
31✔
1026
    }
1027

1028
    this.metricsCollector.increment('deletes', keys.length)
24✔
1029
    this.metricsCollector.increment('invalidations')
24✔
1030
    this.logger.debug?.('delete', { keys })
24✔
1031
    this.emit('delete', { keys })
28✔
1032
  }
1033

1034
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1035
    if (!this.options.invalidationBus) {
35✔
1036
      return
29✔
1037
    }
1038

1039
    await this.options.invalidationBus.publish(message)
6✔
1040
  }
1041

1042
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1043
    if (message.sourceId === this.instanceId) {
13✔
1044
      return
6✔
1045
    }
1046

1047
    const localLayers = this.layers.filter((layer) => layer.isLocal)
10✔
1048
    if (message.scope === 'clear') {
7✔
1049
      this.maintenance.beginClearEpoch()
2✔
1050
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1051
      await this.tagIndex.clear()
2✔
1052
      this.ttlResolver.clearProfiles()
2✔
1053
      this.circuitBreakerManager.clear()
2✔
1054
      return
2✔
1055
    }
1056

1057
    const keys = message.keys ?? []
5!
1058
    this.maintenance.bumpKeyEpochs(keys)
13✔
1059
    await this.invalidation.deleteKeysFromLayers(localLayers, keys)
13✔
1060

1061
    if (message.operation !== 'write') {
5✔
1062
      for (const key of keys) {
2✔
1063
        await this.tagIndex.remove(key)
3✔
1064
        this.ttlResolver.deleteProfile(key)
3✔
1065
        this.circuitBreakerManager.delete(key)
3✔
1066
      }
1067
    }
1068
  }
1069

1070
  private async getTagsForKey(key: string): Promise<string[]> {
1071
    if (this.tagIndex.tagsForKey) {
5✔
1072
      return this.tagIndex.tagsForKey(key)
4✔
1073
    }
1074
    return []
1✔
1075
  }
1076

1077
  private formatError(error: unknown): string {
1078
    if (error instanceof Error) {
44✔
1079
      return error.message
43✔
1080
    }
1081

1082
    return String(error)
1✔
1083
  }
1084

1085
  private sleep(ms: number): Promise<void> {
1086
    return new Promise((resolve) => setTimeout(resolve, ms))
5✔
1087
  }
1088

1089
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1090
    if (timeoutMs <= 0) {
12✔
1091
      return promise
1✔
1092
    }
1093

1094
    let timer: ReturnType<typeof setTimeout> | undefined
1095
    const observedPromise = promise.then(
11✔
1096
      (value) => ({ kind: 'value' as const, value }),
6✔
1097
      (error) => ({ kind: 'error' as const, error })
2✔
1098
    )
1099
    try {
11✔
1100
      const result = await Promise.race([
11✔
1101
        observedPromise,
1102
        new Promise<T>((_, reject) => {
1103
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
11✔
1104
          timer.unref?.()
11✔
1105
        })
1106
      ])
1107
      if (result !== null && result !== undefined && typeof result === 'object' && 'kind' in result) {
7!
1108
        if (result.kind === 'error') {
7✔
1109
          throw result.error
1✔
1110
        }
1111
        return result.value
6✔
1112
      }
UNCOV
1113
      return result
×
1114
    } finally {
1115
      if (timer) {
11!
1116
        clearTimeout(timer)
11✔
1117
      }
1118
    }
1119
  }
1120

1121
  private shouldBroadcastL1Invalidation(): boolean {
1122
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
153✔
1123
  }
1124

1125
  private async observeOperation<T>(
1126
    name: string,
1127
    attributes: Record<string, unknown> | undefined,
1128
    execute: () => Promise<T>
1129
  ): Promise<T> {
1130
    const id = this.nextOperationId
428✔
1131
    this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER
428✔
1132
    this.emit('operation-start', { id, name, attributes })
428✔
1133

1134
    try {
428✔
1135
      const result = await execute()
428✔
1136
      this.emit('operation-end', {
396✔
1137
        id,
1138
        name,
1139
        attributes,
1140
        success: true,
1141
        result: result === null ? 'null' : undefined
396✔
1142
      })
1143
      return result
428✔
1144
    } catch (error) {
1145
      this.emit('operation-end', {
32✔
1146
        id,
1147
        name,
1148
        attributes,
1149
        success: false,
1150
        error
1151
      })
1152
      throw error
32✔
1153
    }
1154
  }
1155

1156
  private scheduleGenerationCleanup(generation: number): void {
1157
    this.maintenance.scheduleGenerationCleanup(
2✔
1158
      generation,
1159
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1160
      (failedGeneration, error) => {
1161
        this.logger.warn?.('generation-cleanup-error', {
1✔
1162
          generation: failedGeneration,
1163
          error: this.formatError(error)
1164
        })
1165
      }
1166
    )
1167
  }
1168

1169
  private async cleanupGeneration(generation: number): Promise<void> {
1170
    const prefix = `v${generation}:`
3✔
1171
    const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix)
3✔
1172
    for (const batch of planGenerationCleanupBatches(keys, this.options.generationCleanup)) {
2✔
1173
      await this.deleteKeys(batch)
1✔
1174
      await this.publishInvalidation({
1✔
1175
        scope: 'keys',
1176
        keys: batch,
1177
        sourceId: this.instanceId,
1178
        operation: 'invalidate'
1179
      })
1180
    }
1181
  }
1182

1183
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1184
    this.maintenance.initializeWriteBehindTimer(
225✔
1185
      this.options.writeStrategy,
1186
      options,
1187
      this.flushWriteBehindQueue.bind(this)
1188
    )
1189
  }
1190

1191
  private shouldWriteBehind(layer: CacheLayer): boolean {
1192
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
174✔
1193
  }
1194

1195
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1196
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1197
  }
1198

1199
  private async flushWriteBehindQueue(): Promise<void> {
1200
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
26✔
1201
  }
1202

1203
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1204
    const results = await Promise.allSettled(batch.map((operation) => operation()))
4✔
1205
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4✔
1206
    if (failures.length === 0) {
3✔
1207
      return
2✔
1208
    }
1209

1210
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1211
    this.logger.error?.('write-behind-flush-failure', {
1✔
1212
      failed: failures.length,
1213
      total: batch.length,
1214
      errors: failures.map((failure) => this.formatError(failure.reason))
1✔
1215
    })
1216
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1217
  }
1218

1219
  private qualifyKey(key: string): string {
1220
    return qualifyGenerationKey(key, this.currentGeneration)
448✔
1221
  }
1222

1223
  private qualifyPattern(pattern: string): string {
1224
    return qualifyGenerationPattern(pattern, this.currentGeneration)
5✔
1225
  }
1226

1227
  private stripQualifiedKey(key: string): string {
1228
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1229
  }
1230

1231
  private validateConfiguration(): void {
1232
    if (
229✔
1233
      this.options.broadcastL1Invalidation !== undefined &&
234✔
1234
      this.options.publishSetInvalidation !== undefined &&
1235
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1236
    ) {
1237
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1238
    }
1239

1240
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
228✔
1241
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1242
    }
1243

1244
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
226✔
1245
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
226✔
1246
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
226✔
1247
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
226✔
1248
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
226✔
1249
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
226✔
1250
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
226✔
1251
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
226✔
1252
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
226✔
1253
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
226✔
1254
    if (this.options.snapshotMaxBytes !== false) {
226✔
1255
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
224✔
1256
    }
1257
    if (this.options.snapshotMaxEntries !== false) {
225✔
1258
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
224✔
1259
    }
1260
    if (this.options.invalidationMaxKeys !== false) {
225✔
1261
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
223✔
1262
    }
1263
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
225✔
1264
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
225✔
1265
    validateCircuitBreakerOptions(this.options.circuitBreaker)
225✔
1266
    if (typeof this.options.generationCleanup === 'object') {
225✔
1267
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
2✔
1268
    }
1269
    if (this.options.generation !== undefined) {
225✔
1270
      validateNonNegativeNumber('generation', this.options.generation)
5✔
1271
    }
1272
  }
1273

1274
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1275
    if (!options) {
413✔
1276
      return
289✔
1277
    }
1278

1279
    validateLayerNumberOption('options.ttl', options.ttl)
124✔
1280
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
124✔
1281
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
124✔
1282
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
124✔
1283
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
124✔
1284
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
124✔
1285
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
124✔
1286
    validateAdaptiveTtlOptions(options.adaptiveTtl)
124✔
1287
    validateCircuitBreakerOptions(options.circuitBreaker)
124✔
1288
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
124✔
1289
    validateTags(options.tags)
124✔
1290
    if (options.contextOptions && typeof options.contextOptions !== 'function') {
124!
NEW
UNCOV
1291
      throw new Error('options.contextOptions must be a function.')
×
1292
    }
1293
  }
1294

1295
  private assertActive(operation: string): void {
1296
    if (this.isDisconnecting) {
920✔
1297
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1298
    }
1299
  }
1300

1301
  private async awaitStartup(operation: string): Promise<void> {
1302
    this.assertActive(operation)
445✔
1303
    await this.startup
445✔
1304
    this.assertActive(operation)
440✔
1305
  }
1306

1307
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1308
    return this.reader.readLayerEntry(layer, key)
5✔
1309
  }
1310

1311
  private scheduleBackgroundRefresh<T>(key: string, fetcher: () => Promise<T>, options?: CacheGetOptions): void {
1312
    this.reader.runScheduleBackgroundRefresh(key, fetcher, options)
1✔
1313
  }
1314

1315
  private async applyFreshReadPolicies<T>(
1316
    key: string,
1317
    hit: {
1318
      found: true
1319
      value: T | null
1320
      stored: unknown
1321
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
1322
      layerIndex: number
1323
      layerName: string
1324
    },
1325
    options: CacheGetOptions | undefined,
1326
    fetcher?: () => Promise<T>
1327
  ): Promise<void> {
1328
    return this.reader.runApplyFreshReadPolicies(key, hit, options, fetcher)
2✔
1329
  }
1330

1331
  private shouldSkipLayer(layer: CacheLayer): boolean {
1332
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
662✔
1333
    const skip = shouldSkipDegradedLayer(degradedUntil)
662✔
1334
    if (!skip && degradedUntil !== undefined) {
662✔
1335
      this.layerDegradedUntil.delete(layer.name)
1✔
1336
    }
1337
    return skip
662✔
1338
  }
1339

1340
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1341
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
13✔
1342
    if (!recovery.degrade) {
13✔
1343
      throw error
4✔
1344
    }
1345

1346
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
9✔
1347
    this.metricsCollector.increment('degradedOperations')
9✔
1348
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
9✔
1349
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
13✔
1350
    return null
13✔
1351
  }
1352

1353
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1354
    if (this.isGracefulDegradationEnabled()) {
6✔
1355
      await this.handleLayerFailure(layer, operation, error)
4✔
1356
      return
4✔
1357
    }
1358

1359
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1360
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
6✔
1361
  }
1362

1363
  private isGracefulDegradationEnabled(): boolean {
1364
    return Boolean(this.options.gracefulDegradation)
8✔
1365
  }
1366

1367
  private recordCircuitFailure(key: string, options: CacheCircuitBreakerOptions | undefined, error: unknown): void {
1368
    if (!options) {
14✔
1369
      return
10✔
1370
    }
1371

1372
    this.circuitBreakerManager.recordFailure(key, options)
4✔
1373
    if (this.circuitBreakerManager.isOpen(key)) {
4!
1374
      this.metricsCollector.increment('circuitBreakerTrips')
4✔
1375
    }
1376
    this.emitError('fetch', { key, error: this.formatError(error) })
4✔
1377
  }
1378

1379
  private emitError(operation: string, context: Record<string, unknown>): void {
1380
    this.logger.error?.(operation, context)
20✔
1381
    if (this.listenerCount('error') > 0) {
20✔
1382
      this.emit('error', { operation, ...context })
5✔
1383
    }
1384
  }
1385

1386
  private snapshotMaxBytes(): number | false {
1387
    return this.options.snapshotMaxBytes === false
10✔
1388
      ? false
1389
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
17✔
1390
  }
1391

1392
  private snapshotMaxEntries(): number | false {
1393
    return this.options.snapshotMaxEntries === false
8✔
1394
      ? false
1395
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
13✔
1396
  }
1397

1398
  private invalidationMaxKeys(): number | false {
1399
    return this.options.invalidationMaxKeys === false
32✔
1400
      ? false
1401
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
55✔
1402
  }
1403
}
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