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

flyingsquirrel0419 / layercache / 25093680746

29 Apr 2026 06:09AM UTC coverage: 95.722% (+0.4%) from 95.339%
25093680746

push

github

web-flow
Merge pull request #20 from flyingsquirrel0419/refactor/extract-cache-stack-reader

refactor: extract CacheStackReader from CacheStack

1604 of 1722 branches covered (93.15%)

Branch coverage included in aggregate %.

214 of 215 new or added lines in 2 files covered. (99.53%)

2894 of 2977 relevant lines covered (97.21%)

334.86 hits per line

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

93.05
/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
11✔
79
const DEFAULT_SNAPSHOT_MAX_ENTRIES = 10_000
11✔
80
const DEFAULT_INVALIDATION_MAX_KEYS = 10_000
11✔
81
const DEFAULT_MAX_PROFILE_ENTRIES = 100_000
11✔
82

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

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

90
  debug(message: string, context?: Record<string, unknown>): void {
91
    this.write('debug', message, context)
587✔
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)
46✔
100
  }
101

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

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

111
    const suffix = context ? ` ${JSON.stringify(context)}` : ''
2✔
112
    console[level](`[layercache] ${message}${suffix}`)
655✔
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()
227✔
130
  private readonly instanceId = createInstanceId()
227✔
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()
227✔
137
  private readonly snapshotSerializer = new JsonSerializer()
227✔
138
  private readonly invalidation: CacheStackInvalidationSupport
139
  private readonly layerWriter: CacheStackLayerWriter
140
  private readonly snapshots: CacheStackSnapshotManager
141
  private readonly layerDegradedUntil = new Map<string, number>()
227✔
142
  private readonly maintenance = new CacheStackMaintenance()
227✔
143
  private readonly ttlResolver: TtlResolver
144
  private readonly circuitBreakerManager: CircuitBreakerManager
145
  private nextOperationId = 0
227✔
146
  private currentGeneration?: number
147
  private isDisconnecting = false
227✔
148
  private readonly reader: CacheStackReader
149
  private disconnectPromise?: Promise<void>
150

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

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

161
    this.validateConfiguration()
226✔
162

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

172
    if (options.publishSetInvalidation !== undefined) {
227✔
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
222✔
179
    this.logger =
227✔
180
      typeof options.logger === 'object' ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv)
650✔
181
    this.tagIndex = options.tagIndex ?? new TagIndex()
227✔
182
    this.keyDiscovery = new CacheKeyDiscovery({
227✔
183
      layers: this.layers,
184
      tagIndex: this.tagIndex,
185
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
16✔
186
      handleLayerFailure: async (layer, operation, error) => {
187
        await this.handleLayerFailure(layer, operation, error)
1✔
188
      }
189
    })
190
    this.invalidation = new CacheStackInvalidationSupport({
227✔
191
      tagIndex: this.tagIndex,
192
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
34✔
193
      handleLayerFailure: async (layer, operation, error) => {
194
        await this.handleLayerFailure(layer, operation, error)
×
195
      }
196
    })
197
    this.layerWriter = new CacheStackLayerWriter({
227✔
198
      layers: this.layers,
199
      maintenance: this.maintenance,
200
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
181✔
201
      shouldWriteBehind: (layer) => this.shouldWriteBehind(layer),
172✔
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)) {
252✔
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)) {
253✔
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 (
222✔
230
      options.invalidationBus &&
251✔
231
      options.broadcastL1Invalidation === undefined &&
232
      options.publishSetInvalidation === undefined
233
    ) {
234
      this.logger.warn?.(
12✔
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({
222✔
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({
222✔
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),
386✔
261
      handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
5✔
262
      emit: (event, data) => this.emit(event, data as never),
361✔
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),
45✔
266
      recordCircuitFailure: (key, options, error) => this.recordCircuitFailure(key, options, error),
12✔
267
      resolveLayerSeconds: (layerName, override, globalDefault, fallback) =>
268
        this.resolveLayerSeconds(layerName, override, globalDefault, fallback),
70✔
269
      sleep: (ms) => this.sleep(ms),
5✔
270
      withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
9✔
271
      isDisconnecting: () => this.isDisconnecting,
10✔
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)
222✔
288
    this.startup = this.initialize()
222✔
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 () => {
273✔
299
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
273✔
300
      this.validateWriteOptions(options)
273✔
301
      await this.awaitStartup('get')
273✔
302
      return this.reader.getPrepared(normalizedKey, fetcher, options)
267✔
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))
8✔
332
    await this.awaitStartup('has')
8✔
333

334
    for (const layer of this.layers) {
8✔
335
      if (this.shouldSkipLayer(layer)) {
15!
336
        continue
×
337
      }
338
      if (layer.has) {
15✔
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 {
10✔
350
          const value = await layer.get(normalizedKey)
10✔
351
          if (value !== null) {
7✔
352
            return true
2✔
353
          }
354
        } catch (error) {
355
          await this.reportRecoverableLayerFailure(layer, 'has', error)
3✔
356
          // fall through
357
        }
358
      }
359
    }
360
    return false
4✔
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 () => {
101✔
394
      const normalizedKey = this.qualifyKey(validateCacheKey(key))
101✔
395
      this.validateWriteOptions(options)
101✔
396
      await this.awaitStartup('set')
101✔
397
      await this.storeEntry(normalizedKey, 'value', value, options)
97✔
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)
NEW
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 () => {
10✔
562
      this.assertActive('mset')
10✔
563
      const normalizedEntries = entries.map((entry) => ({
20✔
564
        ...entry,
565
        key: this.qualifyKey(validateCacheKey(entry.key))
566
      }))
567
      normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
20✔
568
      await this.awaitStartup('mset')
10✔
569
      await this.writeBatch(normalizedEntries)
10✔
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)
36✔
631
    return new CacheNamespace(this, prefix)
36✔
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 invalidateByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
4✔
645
    await this.observeOperation('layercache.invalidate_by_tags', undefined, async () => {
4✔
646
      if (tags.length === 0) {
4!
647
        return
×
648
      }
649

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

658
      await this.deleteKeys(keys)
4✔
659
      await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
660
    })
661
  }
662

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

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

686
  getMetrics(): CacheMetricsSnapshot {
687
    return this.metricsCollector.snapshot
189✔
688
  }
689

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

702
  resetMetrics(): void {
703
    this.metricsCollector.reset()
×
704
  }
705

706
  /**
707
   * Returns computed hit-rate statistics (overall and per-layer).
708
   */
709
  getHitRate(): CacheHitRateSnapshot {
710
    return this.metricsCollector.hitRate()
6✔
711
  }
712

713
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
714
    await this.startup
2✔
715

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

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

753
    this.currentGeneration = updatedGeneration
3✔
754
    if (generationToCleanup !== null) {
3✔
755
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
756
    }
757

758
    return this.currentGeneration
3✔
759
  }
760

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

771
    const foundInLayers: string[] = []
3✔
772
    let freshTtlSeconds: number | null = null
3✔
773
    let staleTtlSeconds: number | null = null
3✔
774
    let errorTtlSeconds: number | null = null
3✔
775
    let isStale = false
3✔
776

777
    for (const layer of this.layers) {
3✔
778
      if (this.shouldSkipLayer(layer)) {
3!
779
        continue
×
780
      }
781
      const stored = await this.readLayerEntry(layer, normalizedKey)
3✔
782
      if (stored === null) {
3✔
783
        continue
1✔
784
      }
785

786
      const resolved = resolveStoredValue(stored)
2✔
787
      if (resolved.state === 'expired') {
2!
788
        continue
×
789
      }
790

791
      foundInLayers.push(layer.name)
2✔
792

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

812
    if (foundInLayers.length === 0) {
3✔
813
      return null
1✔
814
    }
815

816
    const tags = await this.getTagsForKey(normalizedKey)
2✔
817

818
    return { key: userKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags }
2✔
819
  }
820

821
  async exportState(): Promise<CacheSnapshotEntry[]> {
822
    await this.awaitStartup('exportState')
2✔
823
    return this.snapshots.exportState(this.snapshotMaxEntries())
2✔
824
  }
825

826
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
827
    await this.awaitStartup('importState')
1✔
828
    await this.snapshots.importState(entries)
1✔
829
  }
830

831
  async persistToFile(filePath: string): Promise<void> {
832
    this.assertActive('persistToFile')
4✔
833
    await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries())
4✔
834
  }
835

836
  async restoreFromFile(filePath: string): Promise<void> {
837
    this.assertActive('restoreFromFile')
8✔
838
    await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes())
8✔
839
  }
840

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

870
    await this.disconnectPromise
26✔
871
  }
872

873
  private async initialize(): Promise<void> {
874
    if (!this.options.invalidationBus) {
222✔
875
      return
206✔
876
    }
877

878
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
16✔
879
      await this.handleInvalidationMessage(message)
10✔
880
    })
881
  }
882

883
  private async storeEntry(
884
    key: string,
885
    kind: CacheWriteKind,
886
    value: unknown,
887
    options?: CacheWriteOptions
888
  ): Promise<void> {
889
    const clearEpoch = this.maintenance.currentClearEpoch()
142✔
890
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
142✔
891
    await this.layerWriter.writeAcrossLayers(key, kind, value, options)
142✔
892
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
142!
893
      return
×
894
    }
895
    if (options?.tags) {
142✔
896
      await this.tagIndex.track(key, options.tags)
20✔
897
    } else {
898
      await this.tagIndex.touch(key)
122✔
899
    }
900

901
    this.metricsCollector.increment('sets')
142✔
902
    this.logger.debug?.('set', { key, kind, tags: options?.tags })
142✔
903
    this.emit('set', { key, kind: kind as string, tags: options?.tags })
142✔
904
    if (this.shouldBroadcastL1Invalidation()) {
142✔
905
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
906
    }
907
  }
908

909
  private async writeBatch(
910
    entries: Array<{ key: string; value: unknown; options?: CacheWriteOptions }>
911
  ): Promise<void> {
912
    const { clearEpoch, entryEpochs } = await this.layerWriter.writeBatch(entries)
10✔
913
    if (clearEpoch !== this.maintenance.currentClearEpoch()) {
9!
914
      return
×
915
    }
916

917
    for (const entry of entries) {
9✔
918
      if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
18!
919
        continue
×
920
      }
921
      if (entry.options?.tags) {
18!
922
        await this.tagIndex.track(entry.key, entry.options.tags)
×
923
      } else {
924
        await this.tagIndex.touch(entry.key)
18✔
925
      }
926

927
      this.metricsCollector.increment('sets')
18✔
928
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
18✔
929
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
18✔
930
    }
931

932
    if (this.shouldBroadcastL1Invalidation()) {
9✔
933
      await this.publishInvalidation({
1✔
934
        scope: 'keys',
935
        keys: entries.map((entry) => entry.key),
2✔
936
        sourceId: this.instanceId,
937
        operation: 'write'
938
      })
939
    }
940
  }
941

942
  private resolveFreshTtl(
943
    key: string,
944
    layerName: string,
945
    kind: CacheWriteKind,
946
    options: CacheWriteOptions | undefined,
947
    fallbackTtl: number | undefined,
948
    value: unknown
949
  ): number | undefined {
950
    return this.ttlResolver.resolveFreshTtl(
181✔
951
      key,
952
      layerName,
953
      kind,
954
      options,
955
      fallbackTtl,
956
      this.options.negativeTtl,
957
      undefined,
958
      value
959
    )
960
  }
961

962
  private resolveLayerSeconds(
963
    layerName: string,
964
    override: number | LayerTtlMap | undefined,
965
    globalDefault?: number | LayerTtlMap,
966
    fallback?: number
967
  ): number | undefined {
968
    return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback)
432✔
969
  }
970

971
  private async deleteKeys(keys: string[]): Promise<void> {
972
    if (keys.length === 0) {
28✔
973
      return
4✔
974
    }
975

976
    this.maintenance.bumpKeyEpochs(keys)
24✔
977
    await this.invalidation.deleteKeysFromLayers(this.layers, keys)
24✔
978

979
    for (const key of keys) {
24✔
980
      await this.tagIndex.remove(key)
31✔
981
      this.ttlResolver.deleteProfile(key)
31✔
982
      this.circuitBreakerManager.delete(key)
31✔
983
    }
984

985
    this.metricsCollector.increment('deletes', keys.length)
24✔
986
    this.metricsCollector.increment('invalidations')
24✔
987
    this.logger.debug?.('delete', { keys })
24✔
988
    this.emit('delete', { keys })
28✔
989
  }
990

991
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
992
    if (!this.options.invalidationBus) {
35✔
993
      return
29✔
994
    }
995

996
    await this.options.invalidationBus.publish(message)
6✔
997
  }
998

999
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1000
    if (message.sourceId === this.instanceId) {
13✔
1001
      return
6✔
1002
    }
1003

1004
    const localLayers = this.layers.filter((layer) => layer.isLocal)
10✔
1005
    if (message.scope === 'clear') {
7✔
1006
      this.maintenance.beginClearEpoch()
2✔
1007
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1008
      await this.tagIndex.clear()
2✔
1009
      this.ttlResolver.clearProfiles()
2✔
1010
      this.circuitBreakerManager.clear()
2✔
1011
      return
2✔
1012
    }
1013

1014
    const keys = message.keys ?? []
5!
1015
    this.maintenance.bumpKeyEpochs(keys)
13✔
1016
    await this.invalidation.deleteKeysFromLayers(localLayers, keys)
13✔
1017

1018
    if (message.operation !== 'write') {
5✔
1019
      for (const key of keys) {
2✔
1020
        await this.tagIndex.remove(key)
3✔
1021
        this.ttlResolver.deleteProfile(key)
3✔
1022
        this.circuitBreakerManager.delete(key)
3✔
1023
      }
1024
    }
1025
  }
1026

1027
  private async getTagsForKey(key: string): Promise<string[]> {
1028
    if (this.tagIndex.tagsForKey) {
4✔
1029
      return this.tagIndex.tagsForKey(key)
3✔
1030
    }
1031
    return []
1✔
1032
  }
1033

1034
  private formatError(error: unknown): string {
1035
    if (error instanceof Error) {
43✔
1036
      return error.message
42✔
1037
    }
1038

1039
    return String(error)
1✔
1040
  }
1041

1042
  private sleep(ms: number): Promise<void> {
1043
    return new Promise((resolve) => setTimeout(resolve, ms))
5✔
1044
  }
1045

1046
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1047
    if (timeoutMs <= 0) {
12✔
1048
      return promise
1✔
1049
    }
1050

1051
    let timer: ReturnType<typeof setTimeout> | undefined
1052
    const observedPromise = promise.then(
11✔
1053
      (value) => ({ kind: 'value' as const, value }),
6✔
1054
      (error) => ({ kind: 'error' as const, error })
2✔
1055
    )
1056
    try {
11✔
1057
      const result = await Promise.race([
11✔
1058
        observedPromise,
1059
        new Promise<T>((_, reject) => {
1060
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
11✔
1061
          timer.unref?.()
11✔
1062
        })
1063
      ])
1064
      if (result !== null && result !== undefined && typeof result === 'object' && 'kind' in result) {
7!
1065
        if (result.kind === 'error') {
7✔
1066
          throw result.error
1✔
1067
        }
1068
        return result.value
6✔
1069
      }
1070
      return result
×
1071
    } finally {
1072
      if (timer) {
11!
1073
        clearTimeout(timer)
11✔
1074
      }
1075
    }
1076
  }
1077

1078
  private shouldBroadcastL1Invalidation(): boolean {
1079
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
151✔
1080
  }
1081

1082
  private async observeOperation<T>(
1083
    name: string,
1084
    attributes: Record<string, unknown> | undefined,
1085
    execute: () => Promise<T>
1086
  ): Promise<T> {
1087
    const id = this.nextOperationId
424✔
1088
    this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER
424✔
1089
    this.emit('operation-start', { id, name, attributes })
424✔
1090

1091
    try {
424✔
1092
      const result = await execute()
424✔
1093
      this.emit('operation-end', {
393✔
1094
        id,
1095
        name,
1096
        attributes,
1097
        success: true,
1098
        result: result === null ? 'null' : undefined
393✔
1099
      })
1100
      return result
424✔
1101
    } catch (error) {
1102
      this.emit('operation-end', {
31✔
1103
        id,
1104
        name,
1105
        attributes,
1106
        success: false,
1107
        error
1108
      })
1109
      throw error
31✔
1110
    }
1111
  }
1112

1113
  private scheduleGenerationCleanup(generation: number): void {
1114
    this.maintenance.scheduleGenerationCleanup(
2✔
1115
      generation,
1116
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1117
      (failedGeneration, error) => {
1118
        this.logger.warn?.('generation-cleanup-error', {
1✔
1119
          generation: failedGeneration,
1120
          error: this.formatError(error)
1121
        })
1122
      }
1123
    )
1124
  }
1125

1126
  private async cleanupGeneration(generation: number): Promise<void> {
1127
    const prefix = `v${generation}:`
3✔
1128
    const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix)
3✔
1129
    for (const batch of planGenerationCleanupBatches(keys, this.options.generationCleanup)) {
2✔
1130
      await this.deleteKeys(batch)
1✔
1131
      await this.publishInvalidation({
1✔
1132
        scope: 'keys',
1133
        keys: batch,
1134
        sourceId: this.instanceId,
1135
        operation: 'invalidate'
1136
      })
1137
    }
1138
  }
1139

1140
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1141
    this.maintenance.initializeWriteBehindTimer(
222✔
1142
      this.options.writeStrategy,
1143
      options,
1144
      this.flushWriteBehindQueue.bind(this)
1145
    )
1146
  }
1147

1148
  private shouldWriteBehind(layer: CacheLayer): boolean {
1149
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
172✔
1150
  }
1151

1152
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1153
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1154
  }
1155

1156
  private async flushWriteBehindQueue(): Promise<void> {
1157
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
26✔
1158
  }
1159

1160
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1161
    const results = await Promise.allSettled(batch.map((operation) => operation()))
4✔
1162
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4✔
1163
    if (failures.length === 0) {
3✔
1164
      return
2✔
1165
    }
1166

1167
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1168
    this.logger.error?.('write-behind-flush-failure', {
1✔
1169
      failed: failures.length,
1170
      total: batch.length,
1171
      errors: failures.map((failure) => this.formatError(failure.reason))
1✔
1172
    })
1173
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1174
  }
1175

1176
  private qualifyKey(key: string): string {
1177
    return qualifyGenerationKey(key, this.currentGeneration)
443✔
1178
  }
1179

1180
  private qualifyPattern(pattern: string): string {
1181
    return qualifyGenerationPattern(pattern, this.currentGeneration)
5✔
1182
  }
1183

1184
  private stripQualifiedKey(key: string): string {
1185
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1186
  }
1187

1188
  private validateConfiguration(): void {
1189
    if (
226✔
1190
      this.options.broadcastL1Invalidation !== undefined &&
231✔
1191
      this.options.publishSetInvalidation !== undefined &&
1192
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1193
    ) {
1194
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1195
    }
1196

1197
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
225✔
1198
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1199
    }
1200

1201
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
223✔
1202
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
223✔
1203
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
223✔
1204
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
223✔
1205
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
223✔
1206
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
223✔
1207
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
223✔
1208
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
223✔
1209
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
223✔
1210
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
223✔
1211
    if (this.options.snapshotMaxBytes !== false) {
223✔
1212
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
221✔
1213
    }
1214
    if (this.options.snapshotMaxEntries !== false) {
222✔
1215
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
221✔
1216
    }
1217
    if (this.options.invalidationMaxKeys !== false) {
222✔
1218
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
220✔
1219
    }
1220
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
222✔
1221
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
222✔
1222
    validateCircuitBreakerOptions(this.options.circuitBreaker)
222✔
1223
    if (typeof this.options.generationCleanup === 'object') {
222✔
1224
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
2✔
1225
    }
1226
    if (this.options.generation !== undefined) {
222✔
1227
      validateNonNegativeNumber('generation', this.options.generation)
5✔
1228
    }
1229
  }
1230

1231
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1232
    if (!options) {
409✔
1233
      return
288✔
1234
    }
1235

1236
    validateLayerNumberOption('options.ttl', options.ttl)
121✔
1237
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
121✔
1238
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
121✔
1239
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
121✔
1240
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
121✔
1241
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
121✔
1242
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
121✔
1243
    validateAdaptiveTtlOptions(options.adaptiveTtl)
121✔
1244
    validateCircuitBreakerOptions(options.circuitBreaker)
121✔
1245
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
121✔
1246
    validateTags(options.tags)
121✔
1247
  }
1248

1249
  private assertActive(operation: string): void {
1250
    if (this.isDisconnecting) {
910✔
1251
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1252
    }
1253
  }
1254

1255
  private async awaitStartup(operation: string): Promise<void> {
1256
    this.assertActive(operation)
440✔
1257
    await this.startup
440✔
1258
    this.assertActive(operation)
435✔
1259
  }
1260

1261
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1262
    return this.reader.readLayerEntry(layer, key)
4✔
1263
  }
1264

1265
  private scheduleBackgroundRefresh<T>(key: string, fetcher: () => Promise<T>, options?: CacheGetOptions): void {
1266
    this.reader.runScheduleBackgroundRefresh(key, fetcher, options)
1✔
1267
  }
1268

1269
  private async applyFreshReadPolicies<T>(
1270
    key: string,
1271
    hit: {
1272
      found: true
1273
      value: T | null
1274
      stored: unknown
1275
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
1276
      layerIndex: number
1277
      layerName: string
1278
    },
1279
    options: CacheGetOptions | undefined,
1280
    fetcher?: () => Promise<T>
1281
  ): Promise<void> {
1282
    return this.reader.runApplyFreshReadPolicies(key, hit, options, fetcher)
2✔
1283
  }
1284

1285
  private shouldSkipLayer(layer: CacheLayer): boolean {
1286
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
653✔
1287
    const skip = shouldSkipDegradedLayer(degradedUntil)
653✔
1288
    if (!skip && degradedUntil !== undefined) {
653✔
1289
      this.layerDegradedUntil.delete(layer.name)
1✔
1290
    }
1291
    return skip
653✔
1292
  }
1293

1294
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1295
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
13✔
1296
    if (!recovery.degrade) {
13✔
1297
      throw error
4✔
1298
    }
1299

1300
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
9✔
1301
    this.metricsCollector.increment('degradedOperations')
9✔
1302
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
9✔
1303
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
13✔
1304
    return null
13✔
1305
  }
1306

1307
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1308
    if (this.isGracefulDegradationEnabled()) {
6✔
1309
      await this.handleLayerFailure(layer, operation, error)
4✔
1310
      return
4✔
1311
    }
1312

1313
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1314
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
6✔
1315
  }
1316

1317
  private isGracefulDegradationEnabled(): boolean {
1318
    return Boolean(this.options.gracefulDegradation)
8✔
1319
  }
1320

1321
  private recordCircuitFailure(key: string, options: CacheCircuitBreakerOptions | undefined, error: unknown): void {
1322
    if (!options) {
14✔
1323
      return
10✔
1324
    }
1325

1326
    this.circuitBreakerManager.recordFailure(key, options)
4✔
1327
    if (this.circuitBreakerManager.isOpen(key)) {
4!
1328
      this.metricsCollector.increment('circuitBreakerTrips')
4✔
1329
    }
1330
    this.emitError('fetch', { key, error: this.formatError(error) })
4✔
1331
  }
1332

1333
  private emitError(operation: string, context: Record<string, unknown>): void {
1334
    this.logger.error?.(operation, context)
20✔
1335
    if (this.listenerCount('error') > 0) {
20✔
1336
      this.emit('error', { operation, ...context })
5✔
1337
    }
1338
  }
1339

1340
  private snapshotMaxBytes(): number | false {
1341
    return this.options.snapshotMaxBytes === false
10✔
1342
      ? false
1343
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
17✔
1344
  }
1345

1346
  private snapshotMaxEntries(): number | false {
1347
    return this.options.snapshotMaxEntries === false
8✔
1348
      ? false
1349
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
13✔
1350
  }
1351

1352
  private invalidationMaxKeys(): number | false {
1353
    return this.options.invalidationMaxKeys === false
32✔
1354
      ? false
1355
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
55✔
1356
  }
1357
}
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