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

flyingsquirrel0419 / layercache / 25215601919

01 May 2026 01:14PM UTC coverage: 95.755% (+0.03%) from 95.722%
25215601919

Pull #33

github

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

1629 of 1748 branches covered (93.19%)

Branch coverage included in aggregate %.

58 of 58 new or added lines in 3 files covered. (100.0%)

3 existing lines in 1 file now uncovered.

2928 of 3011 relevant lines covered (97.24%)

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

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
class DebugLogger implements CacheLogger {
90
  private readonly enabled: boolean
91

92
  constructor(enabled: boolean) {
93
    this.enabled = enabled
222✔
94
  }
95

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

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

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

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

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

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

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

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

157
  constructor(
158
    private readonly layers: CacheLayer[],
234✔
159
    private readonly options: CacheStackOptions = {}
234✔
160
  ) {
161
    super()
234✔
162

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

167
    this.validateConfiguration()
233✔
168

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

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

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

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

316
  /**
317
   * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
318
   * Fetches and caches the value if not already present.
319
   */
320
  async getOrSet<T>(key: string, fetcher: CacheFetcher<T>, options?: CacheGetOptions): Promise<T | null> {
321
    return this.get(key, fetcher, options)
3✔
322
  }
323

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

337
  /**
338
   * Returns true if the given key exists and is not expired in any layer.
339
   */
340
  async has(key: string): Promise<boolean> {
341
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
8✔
342
    await this.awaitStartup('has')
8✔
343

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

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

381
    for (const layer of this.layers) {
4✔
382
      if (this.shouldSkipLayer(layer)) {
8✔
383
        continue
1✔
384
      }
385
      if (layer.ttl) {
7✔
386
        try {
6✔
387
          const remaining = await layer.ttl(normalizedKey)
6✔
388
          if (remaining !== null) {
4✔
389
            return remaining
3✔
390
          }
391
        } catch {
392
          // fall through
393
        }
394
      }
395
    }
396
    return null
1✔
397
  }
398

399
  /**
400
   * Stores a value in all cache layers. Overwrites any existing value.
401
   */
402
  async set<T>(key: string, value: T, options?: CacheWriteOptions): Promise<void> {
403
    await this.observeOperation('layercache.set', { 'layercache.key': String(key ?? '') }, async () => {
104✔
404
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
104✔
405
      this.validateWriteOptions(options)
104✔
406
      await this.awaitStartup('set')
104✔
407
      await this.storeEntry(normalizedKey, 'value', value, options)
99✔
408
    })
409
  }
410

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

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

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

459
  async mget<T>(entries: CacheMGetEntry<T>[]): Promise<Array<T | null>> {
460
    return this.observeOperation('layercache.mget', undefined, async () => {
9✔
461
      this.assertActive('mget')
9✔
462
      if (entries.length === 0) {
9✔
463
        return []
1✔
464
      }
465

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

483
        return Promise.all(
2✔
484
          normalizedEntries.map((entry) => {
485
            const optionsSignature = serializeOptions(entry.options)
4✔
486
            const existing = pendingReads.get(entry.key)
4✔
487
            if (!existing) {
4✔
488
              const promise = this.reader.getPrepared(entry.key, entry.fetch, entry.options)
2✔
489
              pendingReads.set(entry.key, {
2✔
490
                promise,
491
                fetch: entry.fetch,
492
                optionsSignature
493
              })
494
              return promise
2✔
495
            }
496

497
            if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2!
498
              const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key
2!
499
              throw new Error(`mget received conflicting entries for key "${displayKey}".`)
2✔
500
            }
501

502
            return existing.promise
×
503
          })
504
        )
505
      }
506

507
      await this.awaitStartup('mget')
6✔
508
      const pending = new Set<string>()
6✔
509
      const indexesByKey = new Map<string, number[]>()
6✔
510
      const resultsByKey = new Map<string, T | null>()
6✔
511

512
      for (let index = 0; index < normalizedEntries.length; index += 1) {
6✔
513
        const entry = normalizedEntries[index]
14✔
514
        if (!entry) continue
14!
515
        const key = entry.key
14✔
516
        const indexes = indexesByKey.get(key) ?? []
14✔
517
        indexes.push(index)
14✔
518
        indexesByKey.set(key, indexes)
14✔
519
        pending.add(key)
14✔
520
      }
521

522
      for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
6✔
523
        const layer = this.layers[layerIndex]
6✔
524
        if (!layer || this.shouldSkipLayer(layer)) continue
6!
525
        const keys = [...pending]
6✔
526
        if (keys.length === 0) {
6!
527
          break
×
528
        }
529

530
        const values = layer.getMany
6!
531
          ? await layer.getMany(keys)
532
          : await Promise.all(keys.map((key) => this.reader.readLayerEntry(layer, key)))
×
533

534
        for (let offset = 0; offset < values.length; offset += 1) {
×
535
          const key = keys[offset]
13✔
536
          const stored = values[offset]
13✔
537
          if (!key || stored === null) {
13✔
538
            continue
2✔
539
          }
540

541
          const resolved = resolveStoredValue<T>(stored)
11✔
542
          if (resolved.state === 'expired') {
11✔
543
            await layer.delete(key)
1✔
544
            continue
1✔
545
          }
546

547
          if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
10!
548
            this.metricsCollector.increment('staleHits', indexesByKey.get(key)?.length ?? 1)
×
549
          }
550

551
          await this.tagIndex.touch(key)
10✔
552
          await this.reader.backfill(key, stored, layerIndex - 1)
10✔
553
          resultsByKey.set(key, resolved.value)
10✔
554
          pending.delete(key)
10✔
555
          this.metricsCollector.increment('hits', indexesByKey.get(key)?.length ?? 1)
10!
556
        }
557
      }
558

559
      if (pending.size > 0) {
6✔
560
        for (const key of pending) {
2✔
561
          await this.tagIndex.remove(key)
3✔
562
          this.metricsCollector.increment('misses', indexesByKey.get(key)?.length ?? 1)
3!
563
        }
564
      }
565

566
      return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null)
14✔
567
    })
568
  }
569

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

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

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

614
    await Promise.all(workers)
4✔
615
  }
616

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

635
  /**
636
   * Creates a `CacheNamespace` that automatically prefixes all keys with
637
   * `prefix:`. Useful for multi-tenant or module-level isolation.
638
   */
639
  namespace(prefix: string): CacheNamespace {
640
    validateNamespaceKey(prefix)
36✔
641
    return new CacheNamespace(this, prefix)
36✔
642
  }
643

644
  async invalidateByTag(tag: string): Promise<void> {
645
    await this.observeOperation('layercache.invalidate_by_tag', undefined, async () => {
8✔
646
      validateTag(tag)
8✔
647
      await this.awaitStartup('invalidateByTag')
8✔
648
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
7✔
649
      await this.deleteKeys(keys)
6✔
650
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
651
    })
652
  }
653

654
  async invalidateByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
4✔
655
    await this.observeOperation('layercache.invalidate_by_tags', undefined, async () => {
4✔
656
      if (tags.length === 0) {
4!
657
        return
×
658
      }
659

660
      validateTags(tags)
4✔
661
      await this.awaitStartup('invalidateByTags')
4✔
662
      const keysByTag = await Promise.all(
4✔
663
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
7✔
664
      )
665
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
3✔
666
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
4✔
667

668
      await this.deleteKeys(keys)
4✔
669
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
670
    })
671
  }
672

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

686
  async invalidateByPrefix(prefix: string): Promise<void> {
687
    await this.observeOperation('layercache.invalidate_by_prefix', undefined, async () => {
7✔
688
      await this.awaitStartup('invalidateByPrefix')
7✔
689
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
7✔
690
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
7✔
691
      await this.deleteKeys(keys)
6✔
692
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
693
    })
694
  }
695

696
  getMetrics(): CacheMetricsSnapshot {
697
    return this.metricsCollector.snapshot
189✔
698
  }
699

700
  getStats(): CacheStatsSnapshot {
701
    return {
29✔
702
      metrics: this.getMetrics(),
703
      layers: this.layers.map((layer) => ({
33✔
704
        name: layer.name,
705
        isLocal: Boolean(layer.isLocal),
706
        degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
62✔
707
      })),
708
      backgroundRefreshes: this.reader.activeRefreshCount
709
    }
710
  }
711

712
  resetMetrics(): void {
713
    this.metricsCollector.reset()
×
714
  }
715

716
  /**
717
   * Returns computed hit-rate statistics (overall and per-layer).
718
   */
719
  getHitRate(): CacheHitRateSnapshot {
720
    return this.metricsCollector.hitRate()
6✔
721
  }
722

723
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
724
    await this.startup
2✔
725

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

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

763
    this.currentGeneration = updatedGeneration
3✔
764
    if (generationToCleanup !== null) {
3✔
765
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
766
    }
767

768
    return this.currentGeneration
3✔
769
  }
770

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

781
    const foundInLayers: string[] = []
5✔
782
    let freshTtlSeconds: number | null = null
5✔
783
    let staleTtlSeconds: number | null = null
5✔
784
    let errorTtlSeconds: number | null = null
5✔
785
    let isStale = false
5✔
786

787
    for (const layer of this.layers) {
5✔
788
      if (this.shouldSkipLayer(layer)) {
5!
789
        continue
×
790
      }
791
      const stored = await this.readLayerEntry(layer, normalizedKey)
5✔
792
      if (stored === null) {
5✔
793
        continue
1✔
794
      }
795

796
      const resolved = resolveStoredValue(stored)
4✔
797
      if (resolved.state === 'expired') {
4!
798
        continue
×
799
      }
800

801
      foundInLayers.push(layer.name)
4✔
802

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

822
    if (foundInLayers.length === 0) {
5✔
823
      return null
1✔
824
    }
825

826
    const tags = await this.getTagsForKey(normalizedKey)
4✔
827

828
    return { key: userKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags }
4✔
829
  }
830

831
  async exportState(): Promise<CacheSnapshotEntry[]> {
832
    await this.awaitStartup('exportState')
2✔
833
    return this.snapshots.exportState(this.snapshotMaxEntries())
2✔
834
  }
835

836
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
837
    await this.awaitStartup('importState')
1✔
838
    await this.snapshots.importState(entries)
1✔
839
  }
840

841
  async persistToFile(filePath: string): Promise<void> {
842
    this.assertActive('persistToFile')
4✔
843
    await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries())
4✔
844
  }
845

846
  async restoreFromFile(filePath: string): Promise<void> {
847
    this.assertActive('restoreFromFile')
8✔
848
    await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes())
8✔
849
  }
850

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

880
    await this.disconnectPromise
26✔
881
  }
882

883
  private async initialize(): Promise<void> {
884
    if (!this.options.invalidationBus) {
229✔
885
      return
213✔
886
    }
887

888
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
16✔
889
      await this.handleInvalidationMessage(message)
10✔
890
    })
891
  }
892

893
  private async storeEntry(
894
    key: string,
895
    kind: CacheWriteKind,
896
    value: unknown,
897
    options?: CacheWriteOptions
898
  ): Promise<void> {
899
    const resolvedOptions = this.resolveContextOptions(key, kind, value, options)
150✔
900
    const clearEpoch = this.maintenance.currentClearEpoch()
150✔
901
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
150✔
902
    await this.layerWriter.writeAcrossLayers(key, kind, value, resolvedOptions)
150✔
903
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
145!
UNCOV
904
      return
×
905
    }
906
    if (resolvedOptions?.tags) {
145✔
907
      await this.tagIndex.track(key, resolvedOptions.tags)
22✔
908
    } else {
909
      await this.tagIndex.touch(key)
123✔
910
    }
911

912
    this.metricsCollector.increment('sets')
145✔
913
    this.logger.debug?.('set', { key, kind, tags: resolvedOptions?.tags })
145✔
914
    this.emit('set', { key, kind: kind as string, tags: resolvedOptions?.tags })
150✔
915
    if (this.shouldBroadcastL1Invalidation()) {
150✔
916
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
917
    }
918
  }
919

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

932
    for (const entry of resolvedEntries) {
9✔
933
      if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
18!
UNCOV
934
        continue
×
935
      }
936
      if (entry.options?.tags) {
18!
UNCOV
937
        await this.tagIndex.track(entry.key, entry.options.tags)
×
938
      } else {
939
        await this.tagIndex.touch(entry.key)
18✔
940
      }
941

942
      this.metricsCollector.increment('sets')
18✔
943
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
18✔
944
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
18✔
945
    }
946

947
    if (this.shouldBroadcastL1Invalidation()) {
9✔
948
      await this.publishInvalidation({
1✔
949
        scope: 'keys',
950
        keys: entries.map((entry) => entry.key),
2✔
951
        sourceId: this.instanceId,
952
        operation: 'write'
953
      })
954
    }
955
  }
956

957
  private resolveFreshTtl(
958
    key: string,
959
    layerName: string,
960
    kind: CacheWriteKind,
961
    options: CacheWriteOptions | undefined,
962
    fallbackTtl: number | undefined,
963
    value: unknown
964
  ): number | undefined {
965
    return this.ttlResolver.resolveFreshTtl(
184✔
966
      key,
967
      layerName,
968
      kind,
969
      options,
970
      fallbackTtl,
971
      this.options.negativeTtl,
972
      undefined,
973
      value
974
    )
975
  }
976

977
  private resolveLayerSeconds(
978
    layerName: string,
979
    override: number | LayerTtlMap | undefined,
980
    globalDefault?: number | LayerTtlMap,
981
    fallback?: number
982
  ): number | undefined {
983
    return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback)
438✔
984
  }
985

986
  private resolveContextOptions(
987
    key: string,
988
    kind: CacheEntryWriteKind,
989
    value: unknown,
990
    options: CacheWriteOptions | undefined
991
  ): CacheWriteOptions | undefined {
992
    if (!options?.contextOptions) {
170✔
993
      return options
162✔
994
    }
995

996
    const { contextOptions, ...baseOptions } = options
8✔
997
    let overrides: CacheEntryWriteOptions | undefined
998
    try {
8✔
999
      overrides = contextOptions({ key, value, kind } as CacheContextOptionsContext)
8✔
1000
    } catch (error) {
1001
      throw new Error(`options.contextOptions() failed for key "${key}": ${this.formatError(error)}`)
1✔
1002
    }
1003
    if (!overrides) {
7✔
1004
      return baseOptions
1✔
1005
    }
1006
    if (!this.isPlainObject(overrides)) {
6✔
1007
      throw new Error(
3✔
1008
        `options.contextOptions() must return a plain object or undefined for key "${key}". Async resolvers are not supported.`
1009
      )
1010
    }
1011

1012
    try {
3✔
1013
      validateContextEntryOptions('options.contextOptions()', overrides)
3✔
1014
    } catch (error) {
1015
      throw new Error(
1✔
1016
        `options.contextOptions() returned invalid entry options for key "${key}": ${this.formatError(error)}`
1017
      )
1018
    }
1019
    return {
2✔
1020
      ...baseOptions,
1021
      ...overrides
1022
    }
1023
  }
1024

1025
  private isPlainObject(value: unknown): value is Record<string, unknown> {
1026
    if (!value || typeof value !== 'object' || Array.isArray(value)) {
6✔
1027
      return false
2✔
1028
    }
1029

1030
    const prototype = Object.getPrototypeOf(value)
4✔
1031
    return prototype === Object.prototype || prototype === null
4✔
1032
  }
1033

1034
  private async deleteKeys(keys: string[]): Promise<void> {
1035
    if (keys.length === 0) {
28✔
1036
      return
4✔
1037
    }
1038

1039
    this.maintenance.bumpKeyEpochs(keys)
24✔
1040
    await this.invalidation.deleteKeysFromLayers(this.layers, keys)
24✔
1041

1042
    for (const key of keys) {
24✔
1043
      await this.tagIndex.remove(key)
31✔
1044
      this.ttlResolver.deleteProfile(key)
31✔
1045
      this.circuitBreakerManager.delete(key)
31✔
1046
    }
1047

1048
    this.metricsCollector.increment('deletes', keys.length)
24✔
1049
    this.metricsCollector.increment('invalidations')
24✔
1050
    this.logger.debug?.('delete', { keys })
24✔
1051
    this.emit('delete', { keys })
28✔
1052
  }
1053

1054
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1055
    if (!this.options.invalidationBus) {
35✔
1056
      return
29✔
1057
    }
1058

1059
    await this.options.invalidationBus.publish(message)
6✔
1060
  }
1061

1062
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1063
    if (message.sourceId === this.instanceId) {
13✔
1064
      return
6✔
1065
    }
1066

1067
    const localLayers = this.layers.filter((layer) => layer.isLocal)
10✔
1068
    if (message.scope === 'clear') {
7✔
1069
      this.maintenance.beginClearEpoch()
2✔
1070
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1071
      await this.tagIndex.clear()
2✔
1072
      this.ttlResolver.clearProfiles()
2✔
1073
      this.circuitBreakerManager.clear()
2✔
1074
      return
2✔
1075
    }
1076

1077
    const keys = message.keys ?? []
5!
1078
    this.maintenance.bumpKeyEpochs(keys)
13✔
1079
    await this.invalidation.deleteKeysFromLayers(localLayers, keys)
13✔
1080

1081
    if (message.operation !== 'write') {
5✔
1082
      for (const key of keys) {
2✔
1083
        await this.tagIndex.remove(key)
3✔
1084
        this.ttlResolver.deleteProfile(key)
3✔
1085
        this.circuitBreakerManager.delete(key)
3✔
1086
      }
1087
    }
1088
  }
1089

1090
  private async getTagsForKey(key: string): Promise<string[]> {
1091
    if (this.tagIndex.tagsForKey) {
6✔
1092
      return this.tagIndex.tagsForKey(key)
5✔
1093
    }
1094
    return []
1✔
1095
  }
1096

1097
  private formatError(error: unknown): string {
1098
    if (error instanceof Error) {
45✔
1099
      return error.message
44✔
1100
    }
1101

1102
    return String(error)
1✔
1103
  }
1104

1105
  private sleep(ms: number): Promise<void> {
1106
    return new Promise((resolve) => setTimeout(resolve, ms))
5✔
1107
  }
1108

1109
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1110
    if (timeoutMs <= 0) {
12✔
1111
      return promise
1✔
1112
    }
1113

1114
    let timer: ReturnType<typeof setTimeout> | undefined
1115
    const observedPromise = promise.then(
11✔
1116
      (value) => ({ kind: 'value' as const, value }),
6✔
1117
      (error) => ({ kind: 'error' as const, error })
2✔
1118
    )
1119
    try {
11✔
1120
      const result = await Promise.race([
11✔
1121
        observedPromise,
1122
        new Promise<T>((_, reject) => {
1123
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
11✔
1124
          timer.unref?.()
11✔
1125
        })
1126
      ])
1127
      if (result !== null && result !== undefined && typeof result === 'object' && 'kind' in result) {
7!
1128
        if (result.kind === 'error') {
7✔
1129
          throw result.error
1✔
1130
        }
1131
        return result.value
6✔
1132
      }
1133
      return result
×
1134
    } finally {
1135
      if (timer) {
11!
1136
        clearTimeout(timer)
11✔
1137
      }
1138
    }
1139
  }
1140

1141
  private shouldBroadcastL1Invalidation(): boolean {
1142
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
154✔
1143
  }
1144

1145
  private async observeOperation<T>(
1146
    name: string,
1147
    attributes: Record<string, unknown> | undefined,
1148
    execute: () => Promise<T>
1149
  ): Promise<T> {
1150
    const id = this.nextOperationId
434✔
1151
    this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER
434✔
1152
    this.emit('operation-start', { id, name, attributes })
434✔
1153

1154
    try {
434✔
1155
      const result = await execute()
434✔
1156
      this.emit('operation-end', {
397✔
1157
        id,
1158
        name,
1159
        attributes,
1160
        success: true,
1161
        result: result === null ? 'null' : undefined
397✔
1162
      })
1163
      return result
434✔
1164
    } catch (error) {
1165
      this.emit('operation-end', {
37✔
1166
        id,
1167
        name,
1168
        attributes,
1169
        success: false,
1170
        error
1171
      })
1172
      throw error
37✔
1173
    }
1174
  }
1175

1176
  private scheduleGenerationCleanup(generation: number): void {
1177
    this.maintenance.scheduleGenerationCleanup(
2✔
1178
      generation,
1179
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1180
      (failedGeneration, error) => {
1181
        this.logger.warn?.('generation-cleanup-error', {
1✔
1182
          generation: failedGeneration,
1183
          error: this.formatError(error)
1184
        })
1185
      }
1186
    )
1187
  }
1188

1189
  private async cleanupGeneration(generation: number): Promise<void> {
1190
    const prefix = `v${generation}:`
3✔
1191
    const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix)
3✔
1192
    for (const batch of planGenerationCleanupBatches(keys, this.options.generationCleanup)) {
2✔
1193
      await this.deleteKeys(batch)
1✔
1194
      await this.publishInvalidation({
1✔
1195
        scope: 'keys',
1196
        keys: batch,
1197
        sourceId: this.instanceId,
1198
        operation: 'invalidate'
1199
      })
1200
    }
1201
  }
1202

1203
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1204
    this.maintenance.initializeWriteBehindTimer(
229✔
1205
      this.options.writeStrategy,
1206
      options,
1207
      this.flushWriteBehindQueue.bind(this)
1208
    )
1209
  }
1210

1211
  private shouldWriteBehind(layer: CacheLayer): boolean {
1212
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
175✔
1213
  }
1214

1215
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1216
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1217
  }
1218

1219
  private async flushWriteBehindQueue(): Promise<void> {
1220
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
26✔
1221
  }
1222

1223
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1224
    const results = await Promise.allSettled(batch.map((operation) => operation()))
4✔
1225
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4✔
1226
    if (failures.length === 0) {
3✔
1227
      return
2✔
1228
    }
1229

1230
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1231
    this.logger.error?.('write-behind-flush-failure', {
1✔
1232
      failed: failures.length,
1233
      total: batch.length,
1234
      errors: failures.map((failure) => this.formatError(failure.reason))
1✔
1235
    })
1236
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1237
  }
1238

1239
  private qualifyKey(key: string): string {
1240
    return qualifyGenerationKey(key, this.currentGeneration)
455✔
1241
  }
1242

1243
  private qualifyPattern(pattern: string): string {
1244
    return qualifyGenerationPattern(pattern, this.currentGeneration)
5✔
1245
  }
1246

1247
  private stripQualifiedKey(key: string): string {
1248
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1249
  }
1250

1251
  private validateConfiguration(): void {
1252
    if (
233✔
1253
      this.options.broadcastL1Invalidation !== undefined &&
238✔
1254
      this.options.publishSetInvalidation !== undefined &&
1255
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1256
    ) {
1257
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1258
    }
1259

1260
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
232✔
1261
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1262
    }
1263

1264
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
230✔
1265
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
230✔
1266
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
230✔
1267
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
230✔
1268
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
230✔
1269
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
230✔
1270
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
230✔
1271
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
230✔
1272
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
230✔
1273
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
230✔
1274
    if (this.options.snapshotMaxBytes !== false) {
230✔
1275
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
228✔
1276
    }
1277
    if (this.options.snapshotMaxEntries !== false) {
229✔
1278
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
228✔
1279
    }
1280
    if (this.options.invalidationMaxKeys !== false) {
229✔
1281
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
227✔
1282
    }
1283
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
229✔
1284
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
229✔
1285
    validateCircuitBreakerOptions(this.options.circuitBreaker)
229✔
1286
    if (typeof this.options.generationCleanup === 'object') {
229✔
1287
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
2✔
1288
    }
1289
    if (this.options.generation !== undefined) {
229✔
1290
      validateNonNegativeNumber('generation', this.options.generation)
5✔
1291
    }
1292
  }
1293

1294
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1295
    if (!options) {
419✔
1296
      return
289✔
1297
    }
1298

1299
    validateLayerNumberOption('options.ttl', options.ttl)
130✔
1300
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
130✔
1301
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
130✔
1302
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
130✔
1303
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
130✔
1304
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
130✔
1305
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
130✔
1306
    validateAdaptiveTtlOptions(options.adaptiveTtl)
130✔
1307
    validateCircuitBreakerOptions(options.circuitBreaker)
130✔
1308
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
130✔
1309
    validateTags(options.tags)
130✔
1310
    if (options.contextOptions && typeof options.contextOptions !== 'function') {
130✔
1311
      throw new Error('options.contextOptions must be a function.')
1✔
1312
    }
1313
  }
1314

1315
  private assertActive(operation: string): void {
1316
    if (this.isDisconnecting) {
932✔
1317
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1318
    }
1319
  }
1320

1321
  private async awaitStartup(operation: string): Promise<void> {
1322
    this.assertActive(operation)
451✔
1323
    await this.startup
451✔
1324
    this.assertActive(operation)
446✔
1325
  }
1326

1327
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1328
    return this.reader.readLayerEntry(layer, key)
6✔
1329
  }
1330

1331
  private scheduleBackgroundRefresh<T>(
1332
    key: string,
1333
    fetcher: CacheFetcher<T>,
1334
    options?: CacheGetOptions,
1335
    fetcherContext?: CacheFetcherContext<T>
1336
  ): void {
1337
    this.reader.runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext)
1✔
1338
  }
1339

1340
  private async applyFreshReadPolicies<T>(
1341
    key: string,
1342
    hit: {
1343
      found: true
1344
      value: T | null
1345
      stored: unknown
1346
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
1347
      layerIndex: number
1348
      layerName: string
1349
    },
1350
    options: CacheGetOptions | undefined,
1351
    fetcher?: CacheFetcher<T>
1352
  ): Promise<void> {
1353
    return this.reader.runApplyFreshReadPolicies(key, hit, options, fetcher)
2✔
1354
  }
1355

1356
  private shouldSkipLayer(layer: CacheLayer): boolean {
1357
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
673✔
1358
    const skip = shouldSkipDegradedLayer(degradedUntil)
673✔
1359
    if (!skip && degradedUntil !== undefined) {
673✔
1360
      this.layerDegradedUntil.delete(layer.name)
1✔
1361
    }
1362
    return skip
673✔
1363
  }
1364

1365
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1366
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
13✔
1367
    if (!recovery.degrade) {
13✔
1368
      throw error
4✔
1369
    }
1370

1371
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
9✔
1372
    this.metricsCollector.increment('degradedOperations')
9✔
1373
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
9✔
1374
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
13✔
1375
    return null
13✔
1376
  }
1377

1378
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1379
    if (this.isGracefulDegradationEnabled()) {
6✔
1380
      await this.handleLayerFailure(layer, operation, error)
4✔
1381
      return
4✔
1382
    }
1383

1384
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1385
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
6✔
1386
  }
1387

1388
  private isGracefulDegradationEnabled(): boolean {
1389
    return Boolean(this.options.gracefulDegradation)
8✔
1390
  }
1391

1392
  private recordCircuitFailure(key: string, options: CacheCircuitBreakerOptions | undefined, error: unknown): void {
1393
    if (!options) {
14✔
1394
      return
10✔
1395
    }
1396

1397
    this.circuitBreakerManager.recordFailure(key, options)
4✔
1398
    if (this.circuitBreakerManager.isOpen(key)) {
4!
1399
      this.metricsCollector.increment('circuitBreakerTrips')
4✔
1400
    }
1401
    this.emitError('fetch', { key, error: this.formatError(error) })
4✔
1402
  }
1403

1404
  private emitError(operation: string, context: Record<string, unknown>): void {
1405
    this.logger.error?.(operation, context)
20✔
1406
    if (this.listenerCount('error') > 0) {
20✔
1407
      this.emit('error', { operation, ...context })
5✔
1408
    }
1409
  }
1410

1411
  private snapshotMaxBytes(): number | false {
1412
    return this.options.snapshotMaxBytes === false
10✔
1413
      ? false
1414
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
17✔
1415
  }
1416

1417
  private snapshotMaxEntries(): number | false {
1418
    return this.options.snapshotMaxEntries === false
8✔
1419
      ? false
1420
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
13✔
1421
  }
1422

1423
  private invalidationMaxKeys(): number | false {
1424
    return this.options.invalidationMaxKeys === false
32✔
1425
      ? false
1426
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
55✔
1427
  }
1428
}
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