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

flyingsquirrel0419 / layercache / 25222437347

01 May 2026 04:23PM UTC coverage: 95.918% (+0.2%) from 95.722%
25222437347

push

github

web-flow
Merge pull request #34 from flyingsquirrel0419/fix/stale-preserving-expire-clean

feat: add stale-preserving expire APIs

1632 of 1750 branches covered (93.26%)

Branch coverage included in aggregate %.

73 of 75 new or added lines in 4 files covered. (97.33%)

2974 of 3052 relevant lines covered (97.44%)

331.45 hits per line

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

93.43
/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
  validateLayerNumberOption,
32
  validateNonNegativeNumber,
33
  validatePattern,
34
  validatePositiveNumber,
35
  validateRateLimitOptions,
36
  validateTag,
37
  validateTags,
38
  validateTtlPolicy
39
} from './internal/CacheStackValidation'
40
import { CircuitBreakerManager } from './internal/CircuitBreakerManager'
41
import { FetchRateLimiter } from './internal/FetchRateLimiter'
42
import { MetricsCollector } from './internal/MetricsCollector'
43
import { resolveStoredValue } from './internal/StoredValue'
44
import { TtlResolver } from './internal/TtlResolver'
45
import { TagIndex } from './invalidation/TagIndex'
46
import { JsonSerializer } from './serialization/JsonSerializer'
47
import { StampedeGuard } from './stampede/StampedeGuard'
48
import {
49
  type CacheAdaptiveTtlOptions,
50
  type CacheCircuitBreakerOptions,
51
  type CacheGetOptions,
52
  type CacheHealthCheckResult,
53
  type CacheHitRateSnapshot,
54
  type CacheInspectResult,
55
  type CacheLayer,
56
  type CacheLayerSetManyEntry,
57
  type CacheLogger,
58
  type CacheMGetEntry,
59
  type CacheMSetEntry,
60
  type CacheMetricsSnapshot,
61
  CacheMissError,
62
  type CacheSnapshotEntry,
63
  type CacheStackEvents,
64
  type CacheStackOptions,
65
  type CacheStatsSnapshot,
66
  type CacheTagIndex,
67
  type CacheTtlPolicy,
68
  type CacheWarmEntry,
69
  type CacheWarmOptions,
70
  type CacheWarmProgress,
71
  type CacheWrapOptions,
72
  type CacheWriteBehindOptions,
73
  type CacheWriteOptions,
74
  type InvalidationMessage,
75
  type LayerTtlMap
76
} from './types'
77

78
const DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1_024 * 1_024
12✔
79
const DEFAULT_SNAPSHOT_MAX_ENTRIES = 10_000
12✔
80
const DEFAULT_INVALIDATION_MAX_KEYS = 10_000
12✔
81
const DEFAULT_MAX_PROFILE_ENTRIES = 100_000
12✔
82

83
class DebugLogger implements CacheLogger {
84
  private readonly enabled: boolean
85

86
  constructor(enabled: boolean) {
87
    this.enabled = enabled
225✔
88
  }
89

90
  debug(message: string, context?: Record<string, unknown>): void {
91
    this.write('debug', message, context)
624✔
92
  }
93

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

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

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

106
  private write(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
107
    if (!this.enabled) {
702✔
108
      return
700✔
109
    }
110

111
    const suffix = context ? ` ${JSON.stringify(context)}` : ''
2✔
112
    console[level](`[layercache] ${message}${suffix}`)
702✔
113
  }
114
}
115

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

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

151
  constructor(
152
    private readonly layers: CacheLayer[],
237✔
153
    private readonly options: CacheStackOptions = {}
237✔
154
  ) {
155
    super()
237✔
156

157
    if (layers.length === 0) {
237✔
158
      throw new Error('CacheStack requires at least one cache layer.')
1✔
159
    }
160

161
    this.validateConfiguration()
236✔
162

163
    const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES
236✔
164
    this.ttlResolver = new TtlResolver({ maxProfileEntries })
237✔
165
    this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries })
237✔
166
    this.stampedeGuard = new StampedeGuard({
237✔
167
      maxInFlight: options.stampedeMaxInFlight,
168
      entryTimeoutMs: options.stampedeEntryTimeoutMs
169
    })
170
    this.currentGeneration = options.generation
237✔
171

172
    if (options.publishSetInvalidation !== undefined) {
237✔
173
      console.warn(
1✔
174
        '[layercache] CacheStackOptions.publishSetInvalidation is deprecated. ' + 'Use broadcastL1Invalidation instead.'
175
      )
176
    }
177

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

492
            return existing.promise
×
493
          })
494
        )
495
      }
496

497
      await this.awaitStartup('mget')
6✔
498
      const pending = new Set<string>()
6✔
499
      const indexesByKey = new Map<string, number[]>()
6✔
500
      const resultsByKey = new Map<string, T | null>()
6✔
501

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

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

520
        const values = layer.getMany
6!
521
          ? await layer.getMany(keys)
522
          : await Promise.all(keys.map((key) => this.reader.readLayerEntry(layer, key)))
×
523

524
        for (let offset = 0; offset < values.length; offset += 1) {
×
525
          const key = keys[offset]
13✔
526
          const stored = values[offset]
13✔
527
          if (!key || stored === null) {
13✔
528
            continue
2✔
529
          }
530

531
          const resolved = resolveStoredValue<T>(stored)
11✔
532
          if (resolved.state === 'expired') {
11✔
533
            await layer.delete(key)
1✔
534
            continue
1✔
535
          }
536

537
          if (resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error') {
10!
538
            this.metricsCollector.increment('staleHits', indexesByKey.get(key)?.length ?? 1)
×
539
          }
540

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

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

556
      return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null)
14✔
557
    })
558
  }
559

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

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

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

604
    await Promise.all(workers)
4✔
605
  }
606

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

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

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

644
  async expireByTag(tag: string): Promise<void> {
645
    await this.observeOperation('layercache.expire_by_tag', undefined, async () => {
4✔
646
      validateTag(tag)
4✔
647
      await this.awaitStartup('expireByTag')
4✔
648
      const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys())
4✔
649
      await this.expireKeys(keys)
4✔
650
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
4✔
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 expireByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
2✔
674
    await this.observeOperation('layercache.expire_by_tags', undefined, async () => {
2✔
675
      if (tags.length === 0) {
2!
NEW
676
        return
×
677
      }
678

679
      validateTags(tags)
2✔
680
      await this.awaitStartup('expireByTags')
2✔
681
      const keysByTag = await Promise.all(
2✔
682
        tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
4✔
683
      )
684
      const keys = mode === 'all' ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
2!
685
      this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys())
2✔
686

687
      await this.expireKeys(keys)
2✔
688
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
689
    })
690
  }
691

692
  async invalidateByPattern(pattern: string): Promise<void> {
693
    await this.observeOperation('layercache.invalidate_by_pattern', undefined, async () => {
5✔
694
      validatePattern(pattern)
5✔
695
      await this.awaitStartup('invalidateByPattern')
5✔
696
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
5✔
697
        this.qualifyPattern(pattern),
698
        this.invalidationMaxKeys()
699
      )
700
      await this.deleteKeys(keys)
4✔
701
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
4✔
702
    })
703
  }
704

705
  async expireByPattern(pattern: string): Promise<void> {
706
    await this.observeOperation('layercache.expire_by_pattern', undefined, async () => {
2✔
707
      validatePattern(pattern)
2✔
708
      await this.awaitStartup('expireByPattern')
2✔
709
      const keys = await this.keyDiscovery.collectKeysMatchingPattern(
2✔
710
        this.qualifyPattern(pattern),
711
        this.invalidationMaxKeys()
712
      )
713
      await this.expireKeys(keys)
2✔
714
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
715
    })
716
  }
717

718
  async invalidateByPrefix(prefix: string): Promise<void> {
719
    await this.observeOperation('layercache.invalidate_by_prefix', undefined, async () => {
7✔
720
      await this.awaitStartup('invalidateByPrefix')
7✔
721
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
7✔
722
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
7✔
723
      await this.deleteKeys(keys)
6✔
724
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
725
    })
726
  }
727

728
  async expireByPrefix(prefix: string): Promise<void> {
729
    await this.observeOperation('layercache.expire_by_prefix', undefined, async () => {
3✔
730
      await this.awaitStartup('expireByPrefix')
3✔
731
      const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
3✔
732
      const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
3✔
733
      await this.expireKeys(keys)
2✔
734
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'expire' })
2✔
735
    })
736
  }
737

738
  getMetrics(): CacheMetricsSnapshot {
739
    return this.metricsCollector.snapshot
211✔
740
  }
741

742
  getStats(): CacheStatsSnapshot {
743
    return {
28✔
744
      metrics: this.getMetrics(),
745
      layers: this.layers.map((layer) => ({
32✔
746
        name: layer.name,
747
        isLocal: Boolean(layer.isLocal),
748
        degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
60✔
749
      })),
750
      backgroundRefreshes: this.reader.activeRefreshCount
751
    }
752
  }
753

754
  resetMetrics(): void {
755
    this.metricsCollector.reset()
×
756
  }
757

758
  /**
759
   * Returns computed hit-rate statistics (overall and per-layer).
760
   */
761
  getHitRate(): CacheHitRateSnapshot {
762
    return this.metricsCollector.hitRate()
6✔
763
  }
764

765
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
766
    await this.startup
2✔
767

768
    return Promise.all(
2✔
769
      this.layers.map(async (layer) => {
770
        const startedAt = performance.now()
4✔
771
        try {
4✔
772
          const healthy = layer.ping ? await layer.ping() : true
4✔
773
          return {
4✔
774
            layer: layer.name,
775
            healthy,
776
            latencyMs: performance.now() - startedAt
777
          }
778
        } catch (error) {
779
          return {
1✔
780
            layer: layer.name,
781
            healthy: false,
782
            latencyMs: performance.now() - startedAt,
783
            error: this.formatError(error)
784
          }
785
        }
786
      })
787
    )
788
  }
789

790
  /**
791
   * Rotates the active generation prefix used for all future cache keys.
792
   * Previous-generation keys remain in the underlying layers until they expire,
793
   * unless `generationCleanup` is enabled to prune them in the background.
794
   */
795
  bumpGeneration(nextGeneration?: number): number {
796
    const current = this.currentGeneration ?? 0
3!
797
    const previousGeneration = this.currentGeneration
3✔
798
    const updatedGeneration = nextGeneration ?? current + 1
3✔
799
    const generationToCleanup = resolveGenerationCleanupTarget({
3✔
800
      previousGeneration,
801
      nextGeneration: updatedGeneration,
802
      generationCleanup: this.options.generationCleanup
803
    })
804

805
    this.currentGeneration = updatedGeneration
3✔
806
    if (generationToCleanup !== null) {
3✔
807
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
808
    }
809

810
    return this.currentGeneration
3✔
811
  }
812

813
  /**
814
   * Returns detailed metadata about a single cache key: which layers contain it,
815
   * remaining fresh/stale/error TTLs, and associated tags.
816
   * Returns `null` if the key does not exist in any layer.
817
   */
818
  async inspect(key: string): Promise<CacheInspectResult | null> {
819
    const userKey = validateCacheKey(key)
19✔
820
    const normalizedKey = this.qualifyKey(userKey)
19✔
821
    await this.awaitStartup('inspect')
19✔
822

823
    const foundInLayers: string[] = []
19✔
824
    let freshTtlSeconds: number | null = null
19✔
825
    let staleTtlSeconds: number | null = null
19✔
826
    let errorTtlSeconds: number | null = null
19✔
827
    let isStale = false
19✔
828

829
    for (const layer of this.layers) {
19✔
830
      if (this.shouldSkipLayer(layer)) {
20!
831
        continue
×
832
      }
833
      const stored = await this.readLayerEntry(layer, normalizedKey)
20✔
834
      if (stored === null) {
20✔
835
        continue
1✔
836
      }
837

838
      const resolved = resolveStoredValue(stored)
19✔
839
      if (resolved.state === 'expired') {
19!
840
        continue
×
841
      }
842

843
      foundInLayers.push(layer.name)
19✔
844

845
      // Take TTL info from the first (fastest) layer that has it
846
      if (foundInLayers.length === 1 && resolved.envelope) {
19✔
847
        const now = Date.now()
18✔
848
        freshTtlSeconds =
18✔
849
          resolved.envelope.freshUntil !== null
18!
850
            ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1_000))
851
            : null
852
        staleTtlSeconds =
18✔
853
          resolved.envelope.staleUntil !== null
18✔
854
            ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1_000))
855
            : null
856
        errorTtlSeconds =
18✔
857
          resolved.envelope.errorUntil !== null
18✔
858
            ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1_000))
859
            : null
860
        isStale = resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error'
18✔
861
      }
862
    }
863

864
    if (foundInLayers.length === 0) {
19✔
865
      return null
1✔
866
    }
867

868
    const tags = await this.getTagsForKey(normalizedKey)
18✔
869

870
    return { key: userKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags }
18✔
871
  }
872

873
  async exportState(): Promise<CacheSnapshotEntry[]> {
874
    await this.awaitStartup('exportState')
2✔
875
    return this.snapshots.exportState(this.snapshotMaxEntries())
2✔
876
  }
877

878
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
879
    await this.awaitStartup('importState')
1✔
880
    await this.snapshots.importState(entries)
1✔
881
  }
882

883
  async persistToFile(filePath: string): Promise<void> {
884
    this.assertActive('persistToFile')
4✔
885
    await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries())
4✔
886
  }
887

888
  async restoreFromFile(filePath: string): Promise<void> {
889
    this.assertActive('restoreFromFile')
8✔
890
    await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes())
8✔
891
  }
892

893
  async disconnect(): Promise<void> {
894
    if (!this.disconnectPromise) {
28!
895
      this.isDisconnecting = true
28✔
896
      this.disconnectPromise = (async () => {
28✔
897
        await this.startup
28✔
898
        await this.unsubscribeInvalidation?.()
28✔
899
        await this.flushWriteBehindQueue()
28✔
900
        await this.maintenance.waitForGenerationCleanup()
28✔
901
        this.reader.abortAllRefreshes()
28✔
902
        await Promise.allSettled(
28✔
903
          this.reader.getAllRefreshPromises().map((promise) => {
904
            let timer: ReturnType<typeof setTimeout> | undefined
905
            return Promise.race([
×
906
              promise,
907
              new Promise<void>((resolve) => {
908
                timer = setTimeout(resolve, 5_000)
×
909
                timer.unref?.()
×
910
              })
911
            ]).finally(() => {
912
              if (timer) clearTimeout(timer)
×
913
            })
914
          })
915
        )
916
        this.maintenance.disposeWriteBehindTimer()
28✔
917
        this.fetchRateLimiter.dispose()
28✔
918
        await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()))
42✔
919
      })()
920
    }
921

922
    await this.disconnectPromise
28✔
923
  }
924

925
  private async initialize(): Promise<void> {
926
    if (!this.options.invalidationBus) {
232✔
927
      return
214✔
928
    }
929

930
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
18✔
931
      await this.handleInvalidationMessage(message)
12✔
932
    })
933
  }
934

935
  private async storeEntry(
936
    key: string,
937
    kind: CacheWriteKind,
938
    value: unknown,
939
    options?: CacheWriteOptions
940
  ): Promise<void> {
941
    const clearEpoch = this.maintenance.currentClearEpoch()
152✔
942
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
152✔
943
    await this.layerWriter.writeAcrossLayers(key, kind, value, options)
152✔
944
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
152!
945
      return
×
946
    }
947
    if (options?.tags) {
152✔
948
      await this.tagIndex.track(key, options.tags)
26✔
949
    } else {
950
      await this.tagIndex.touch(key)
126✔
951
    }
952

953
    this.metricsCollector.increment('sets')
152✔
954
    this.logger.debug?.('set', { key, kind, tags: options?.tags })
152✔
955
    this.emit('set', { key, kind: kind as string, tags: options?.tags })
152✔
956
    if (this.shouldBroadcastL1Invalidation()) {
152✔
957
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
958
    }
959
  }
960

961
  private async writeBatch(
962
    entries: Array<{ key: string; value: unknown; options?: CacheWriteOptions }>
963
  ): Promise<void> {
964
    const { clearEpoch, entryEpochs } = await this.layerWriter.writeBatch(entries)
12✔
965
    if (clearEpoch !== this.maintenance.currentClearEpoch()) {
11!
966
      return
×
967
    }
968

969
    for (const entry of entries) {
11✔
970
      if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
24!
971
        continue
×
972
      }
973
      if (entry.options?.tags) {
24✔
974
        await this.tagIndex.track(entry.key, entry.options.tags)
2✔
975
      } else {
976
        await this.tagIndex.touch(entry.key)
22✔
977
      }
978

979
      this.metricsCollector.increment('sets')
24✔
980
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
24✔
981
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
24✔
982
    }
983

984
    if (this.shouldBroadcastL1Invalidation()) {
11✔
985
      await this.publishInvalidation({
1✔
986
        scope: 'keys',
987
        keys: entries.map((entry) => entry.key),
2✔
988
        sourceId: this.instanceId,
989
        operation: 'write'
990
      })
991
    }
992
  }
993

994
  private resolveFreshTtl(
995
    key: string,
996
    layerName: string,
997
    kind: CacheWriteKind,
998
    options: CacheWriteOptions | undefined,
999
    fallbackTtl: number | undefined,
1000
    value: unknown
1001
  ): number | undefined {
1002
    return this.ttlResolver.resolveFreshTtl(
199✔
1003
      key,
1004
      layerName,
1005
      kind,
1006
      options,
1007
      fallbackTtl,
1008
      this.options.negativeTtl,
1009
      undefined,
1010
      value
1011
    )
1012
  }
1013

1014
  private resolveLayerSeconds(
1015
    layerName: string,
1016
    override: number | LayerTtlMap | undefined,
1017
    globalDefault?: number | LayerTtlMap,
1018
    fallback?: number
1019
  ): number | undefined {
1020
    return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback)
471✔
1021
  }
1022

1023
  private async deleteKeys(keys: string[]): Promise<void> {
1024
    if (keys.length === 0) {
28✔
1025
      return
4✔
1026
    }
1027

1028
    this.maintenance.bumpKeyEpochs(keys)
24✔
1029
    await this.invalidation.deleteKeysFromLayers(this.layers, keys)
24✔
1030

1031
    for (const key of keys) {
24✔
1032
      await this.tagIndex.remove(key)
31✔
1033
      this.ttlResolver.deleteProfile(key)
31✔
1034
      this.circuitBreakerManager.delete(key)
31✔
1035
    }
1036

1037
    this.metricsCollector.increment('deletes', keys.length)
24✔
1038
    this.metricsCollector.increment('invalidations')
24✔
1039
    this.logger.debug?.('delete', { keys })
24✔
1040
    this.emit('delete', { keys })
28✔
1041
  }
1042

1043
  private async expireKeys(keys: string[]): Promise<void> {
1044
    if (keys.length === 0) {
10!
NEW
1045
      return
×
1046
    }
1047

1048
    this.maintenance.bumpKeyEpochs(keys)
10✔
1049
    const foundKeys = await this.expireKeysInLayers(keys, this.layers)
10✔
1050

1051
    for (const key of keys) {
10✔
1052
      if (foundKeys.has(key)) {
13✔
1053
        continue
12✔
1054
      }
1055

1056
      await this.tagIndex.remove(key)
1✔
1057
      this.ttlResolver.deleteProfile(key)
1✔
1058
      this.circuitBreakerManager.delete(key)
1✔
1059
    }
1060

1061
    this.metricsCollector.increment('invalidations')
10✔
1062
    this.logger.debug?.('expire', { keys })
10✔
1063
    this.emit('expire', { keys })
10✔
1064
  }
1065

1066
  private async expireKeysInLayers(keys: string[], layers: CacheLayer[]): Promise<Set<string>> {
1067
    if (keys.length === 0) {
12✔
1068
      return new Set()
1✔
1069
    }
1070

1071
    return this.invalidation.expireKeysInLayers(layers, keys)
11✔
1072
  }
1073

1074
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1075
    if (!this.options.invalidationBus) {
45✔
1076
      return
38✔
1077
    }
1078

1079
    await this.options.invalidationBus.publish(message)
7✔
1080
  }
1081

1082
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1083
    if (message.sourceId === this.instanceId) {
15✔
1084
      return
7✔
1085
    }
1086

1087
    const localLayers = this.layers.filter((layer) => layer.isLocal)
12✔
1088
    if (message.scope === 'clear') {
8✔
1089
      this.maintenance.beginClearEpoch()
2✔
1090
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1091
      await this.tagIndex.clear()
2✔
1092
      this.ttlResolver.clearProfiles()
2✔
1093
      this.circuitBreakerManager.clear()
2✔
1094
      return
2✔
1095
    }
1096

1097
    const keys = message.keys ?? []
6!
1098
    this.maintenance.bumpKeyEpochs(keys)
15✔
1099
    if (message.operation === 'expire') {
15✔
1100
      await this.expireKeysInLayers(keys, localLayers)
1✔
1101
      return
1✔
1102
    }
1103

1104
    await this.invalidation.deleteKeysFromLayers(localLayers, keys)
5✔
1105

1106
    if (message.operation !== 'write') {
5✔
1107
      for (const key of keys) {
2✔
1108
        await this.tagIndex.remove(key)
3✔
1109
        this.ttlResolver.deleteProfile(key)
3✔
1110
        this.circuitBreakerManager.delete(key)
3✔
1111
      }
1112
    }
1113
  }
1114

1115
  private async getTagsForKey(key: string): Promise<string[]> {
1116
    if (this.tagIndex.tagsForKey) {
20✔
1117
      return this.tagIndex.tagsForKey(key)
19✔
1118
    }
1119
    return []
1✔
1120
  }
1121

1122
  private formatError(error: unknown): string {
1123
    if (error instanceof Error) {
51✔
1124
      return error.message
50✔
1125
    }
1126

1127
    return String(error)
1✔
1128
  }
1129

1130
  private sleep(ms: number): Promise<void> {
1131
    return new Promise((resolve) => setTimeout(resolve, ms))
5✔
1132
  }
1133

1134
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1135
    if (timeoutMs <= 0) {
14✔
1136
      return promise
1✔
1137
    }
1138

1139
    let timer: ReturnType<typeof setTimeout> | undefined
1140
    const observedPromise = promise.then(
13✔
1141
      (value) => ({ kind: 'value' as const, value }),
8✔
1142
      (error) => ({ kind: 'error' as const, error })
2✔
1143
    )
1144
    try {
13✔
1145
      const result = await Promise.race([
13✔
1146
        observedPromise,
1147
        new Promise<T>((_, reject) => {
1148
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
13✔
1149
          timer.unref?.()
13✔
1150
        })
1151
      ])
1152
      if (result !== null && result !== undefined && typeof result === 'object' && 'kind' in result) {
9!
1153
        if (result.kind === 'error') {
9✔
1154
          throw result.error
1✔
1155
        }
1156
        return result.value
8✔
1157
      }
1158
      return result
×
1159
    } finally {
1160
      if (timer) {
13!
1161
        clearTimeout(timer)
13✔
1162
      }
1163
    }
1164
  }
1165

1166
  private shouldBroadcastL1Invalidation(): boolean {
1167
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
163✔
1168
  }
1169

1170
  private async observeOperation<T>(
1171
    name: string,
1172
    attributes: Record<string, unknown> | undefined,
1173
    execute: () => Promise<T>
1174
  ): Promise<T> {
1175
    const id = this.nextOperationId
451✔
1176
    this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER
451✔
1177
    this.emit('operation-start', { id, name, attributes })
451✔
1178

1179
    try {
451✔
1180
      const result = await execute()
451✔
1181
      this.emit('operation-end', {
419✔
1182
        id,
1183
        name,
1184
        attributes,
1185
        success: true,
1186
        result: result === null ? 'null' : undefined
419✔
1187
      })
1188
      return result
451✔
1189
    } catch (error) {
1190
      this.emit('operation-end', {
32✔
1191
        id,
1192
        name,
1193
        attributes,
1194
        success: false,
1195
        error
1196
      })
1197
      throw error
32✔
1198
    }
1199
  }
1200

1201
  private scheduleGenerationCleanup(generation: number): void {
1202
    this.maintenance.scheduleGenerationCleanup(
2✔
1203
      generation,
1204
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1205
      (failedGeneration, error) => {
1206
        this.logger.warn?.('generation-cleanup-error', {
1✔
1207
          generation: failedGeneration,
1208
          error: this.formatError(error)
1209
        })
1210
      }
1211
    )
1212
  }
1213

1214
  private async cleanupGeneration(generation: number): Promise<void> {
1215
    const prefix = `v${generation}:`
3✔
1216
    const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix)
3✔
1217
    for (const batch of planGenerationCleanupBatches(keys, this.options.generationCleanup)) {
2✔
1218
      await this.deleteKeys(batch)
1✔
1219
      await this.publishInvalidation({
1✔
1220
        scope: 'keys',
1221
        keys: batch,
1222
        sourceId: this.instanceId,
1223
        operation: 'invalidate'
1224
      })
1225
    }
1226
  }
1227

1228
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1229
    this.maintenance.initializeWriteBehindTimer(
232✔
1230
      this.options.writeStrategy,
1231
      options,
1232
      this.flushWriteBehindQueue.bind(this)
1233
    )
1234
  }
1235

1236
  private shouldWriteBehind(layer: CacheLayer): boolean {
1237
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
186✔
1238
  }
1239

1240
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1241
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1242
  }
1243

1244
  private async flushWriteBehindQueue(): Promise<void> {
1245
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
28✔
1246
  }
1247

1248
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1249
    const results = await Promise.allSettled(batch.map((operation) => operation()))
4✔
1250
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4✔
1251
    if (failures.length === 0) {
3✔
1252
      return
2✔
1253
    }
1254

1255
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1256
    this.logger.error?.('write-behind-flush-failure', {
1✔
1257
      failed: failures.length,
1258
      total: batch.length,
1259
      errors: failures.map((failure) => this.formatError(failure.reason))
1✔
1260
    })
1261
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1262
  }
1263

1264
  private qualifyKey(key: string): string {
1265
    return qualifyGenerationKey(key, this.currentGeneration)
483✔
1266
  }
1267

1268
  private qualifyPattern(pattern: string): string {
1269
    return qualifyGenerationPattern(pattern, this.currentGeneration)
7✔
1270
  }
1271

1272
  private stripQualifiedKey(key: string): string {
1273
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1274
  }
1275

1276
  private validateConfiguration(): void {
1277
    if (
236✔
1278
      this.options.broadcastL1Invalidation !== undefined &&
241✔
1279
      this.options.publishSetInvalidation !== undefined &&
1280
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1281
    ) {
1282
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1283
    }
1284

1285
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
235✔
1286
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1287
    }
1288

1289
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
233✔
1290
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
233✔
1291
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
233✔
1292
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
233✔
1293
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
233✔
1294
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
233✔
1295
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
233✔
1296
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
233✔
1297
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
233✔
1298
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
233✔
1299
    if (this.options.snapshotMaxBytes !== false) {
233✔
1300
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
231✔
1301
    }
1302
    if (this.options.snapshotMaxEntries !== false) {
232✔
1303
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
231✔
1304
    }
1305
    if (this.options.invalidationMaxKeys !== false) {
232✔
1306
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
230✔
1307
    }
1308
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
232✔
1309
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
232✔
1310
    validateCircuitBreakerOptions(this.options.circuitBreaker)
232✔
1311
    if (typeof this.options.generationCleanup === 'object') {
232✔
1312
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
2✔
1313
    }
1314
    if (this.options.generation !== undefined) {
232✔
1315
      validateNonNegativeNumber('generation', this.options.generation)
5✔
1316
    }
1317
  }
1318

1319
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1320
    if (!options) {
429✔
1321
      return
294✔
1322
    }
1323

1324
    validateLayerNumberOption('options.ttl', options.ttl)
135✔
1325
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
135✔
1326
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
135✔
1327
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
135✔
1328
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
135✔
1329
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
135✔
1330
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
135✔
1331
    validateAdaptiveTtlOptions(options.adaptiveTtl)
135✔
1332
    validateCircuitBreakerOptions(options.circuitBreaker)
135✔
1333
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
135✔
1334
    validateTags(options.tags)
135✔
1335
  }
1336

1337
  private assertActive(operation: string): void {
1338
    if (this.isDisconnecting) {
1,000✔
1339
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1340
    }
1341
  }
1342

1343
  private async awaitStartup(operation: string): Promise<void> {
1344
    this.assertActive(operation)
484✔
1345
    await this.startup
484✔
1346
    this.assertActive(operation)
479✔
1347
  }
1348

1349
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1350
    return this.reader.readLayerEntry(layer, key)
21✔
1351
  }
1352

1353
  private scheduleBackgroundRefresh<T>(key: string, fetcher: () => Promise<T>, options?: CacheGetOptions): void {
1354
    this.reader.runScheduleBackgroundRefresh(key, fetcher, options)
1✔
1355
  }
1356

1357
  private async applyFreshReadPolicies<T>(
1358
    key: string,
1359
    hit: {
1360
      found: true
1361
      value: T | null
1362
      stored: unknown
1363
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
1364
      layerIndex: number
1365
      layerName: string
1366
    },
1367
    options: CacheGetOptions | undefined,
1368
    fetcher?: () => Promise<T>
1369
  ): Promise<void> {
1370
    return this.reader.runApplyFreshReadPolicies(key, hit, options, fetcher)
2✔
1371
  }
1372

1373
  private shouldSkipLayer(layer: CacheLayer): boolean {
1374
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
739✔
1375
    const skip = shouldSkipDegradedLayer(degradedUntil)
739✔
1376
    if (!skip && degradedUntil !== undefined) {
739✔
1377
      this.layerDegradedUntil.delete(layer.name)
1✔
1378
    }
1379
    return skip
739✔
1380
  }
1381

1382
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1383
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
17✔
1384
    if (!recovery.degrade) {
17✔
1385
      throw error
4✔
1386
    }
1387

1388
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
13✔
1389
    this.metricsCollector.increment('degradedOperations')
13✔
1390
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
13✔
1391
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
17✔
1392
    return null
17✔
1393
  }
1394

1395
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1396
    if (this.isGracefulDegradationEnabled()) {
7✔
1397
      await this.handleLayerFailure(layer, operation, error)
5✔
1398
      return
5✔
1399
    }
1400

1401
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1402
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
7✔
1403
  }
1404

1405
  private isGracefulDegradationEnabled(): boolean {
1406
    return Boolean(this.options.gracefulDegradation)
9✔
1407
  }
1408

1409
  private recordCircuitFailure(key: string, options: CacheCircuitBreakerOptions | undefined, error: unknown): void {
1410
    if (!options) {
14✔
1411
      return
10✔
1412
    }
1413

1414
    this.circuitBreakerManager.recordFailure(key, options)
4✔
1415
    if (this.circuitBreakerManager.isOpen(key)) {
4!
1416
      this.metricsCollector.increment('circuitBreakerTrips')
4✔
1417
    }
1418
    this.emitError('fetch', { key, error: this.formatError(error) })
4✔
1419
  }
1420

1421
  private emitError(operation: string, context: Record<string, unknown>): void {
1422
    this.logger.error?.(operation, context)
24✔
1423
    if (this.listenerCount('error') > 0) {
24✔
1424
      this.emit('error', { operation, ...context })
9✔
1425
    }
1426
  }
1427

1428
  private snapshotMaxBytes(): number | false {
1429
    return this.options.snapshotMaxBytes === false
10✔
1430
      ? false
1431
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
17✔
1432
  }
1433

1434
  private snapshotMaxEntries(): number | false {
1435
    return this.options.snapshotMaxEntries === false
8✔
1436
      ? false
1437
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
13✔
1438
  }
1439

1440
  private invalidationMaxKeys(): number | false {
1441
    return this.options.invalidationMaxKeys === false
47✔
1442
      ? false
1443
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
84✔
1444
  }
1445
}
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