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

flyingsquirrel0419 / layercache / 24137798532

08 Apr 2026 01:25PM UTC coverage: 95.589% (+2.5%) from 93.06%
24137798532

push

github

flyingsquirrel0419
Format coverage test updates

1490 of 1596 branches covered (93.36%)

Branch coverage included in aggregate %.

2627 of 2711 relevant lines covered (96.9%)

252.06 hits per line

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

92.91
/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 { readUtf8HandleWithLimit, validateSnapshotFilePath } from './internal/CacheSnapshotFile'
11
import {
12
  generationPrefix,
13
  planGenerationCleanupBatches,
14
  qualifyGenerationKey,
15
  qualifyGenerationPattern,
16
  resolveGenerationCleanupTarget,
17
  stripGenerationPrefix
18
} from './internal/CacheStackGeneration'
19
import { CacheStackMaintenance } from './internal/CacheStackMaintenance'
20
import {
21
  planFreshReadPolicies,
22
  resolveRecoverableLayerFailure,
23
  shouldSkipLayer as shouldSkipDegradedLayer,
24
  shouldStartBackgroundRefresh
25
} from './internal/CacheStackRuntimePolicy'
26
import {
27
  validateAdaptiveTtlOptions,
28
  validateCacheKey,
29
  validateCircuitBreakerOptions,
30
  validateLayerNumberOption,
31
  validateNonNegativeNumber,
32
  validatePattern,
33
  validatePositiveNumber,
34
  validateRateLimitOptions,
35
  validateTag,
36
  validateTags,
37
  validateTtlPolicy
38
} from './internal/CacheStackValidation'
39
import { CircuitBreakerManager } from './internal/CircuitBreakerManager'
40
import { FetchRateLimiter } from './internal/FetchRateLimiter'
41
import { MetricsCollector } from './internal/MetricsCollector'
42
import {
43
  createStoredValueEnvelope,
44
  isStoredValueEnvelope,
45
  remainingStoredTtlSeconds,
46
  resolveStoredValue
47
} from './internal/StoredValue'
48
import { TtlResolver } from './internal/TtlResolver'
49
import { TagIndex } from './invalidation/TagIndex'
50
import { JsonSerializer } from './serialization/JsonSerializer'
51
import { StampedeGuard } from './stampede/StampedeGuard'
52
import {
53
  type CacheAdaptiveTtlOptions,
54
  type CacheCircuitBreakerOptions,
55
  type CacheGetOptions,
56
  type CacheHealthCheckResult,
57
  type CacheHitRateSnapshot,
58
  type CacheInspectResult,
59
  type CacheLayer,
60
  type CacheLayerSetManyEntry,
61
  type CacheLogger,
62
  type CacheMGetEntry,
63
  type CacheMSetEntry,
64
  type CacheMetricsSnapshot,
65
  CacheMissError,
66
  type CacheSingleFlightExecutionOptions,
67
  type CacheSnapshotEntry,
68
  type CacheStackEvents,
69
  type CacheStackOptions,
70
  type CacheStatsSnapshot,
71
  type CacheTagIndex,
72
  type CacheTtlPolicy,
73
  type CacheWarmEntry,
74
  type CacheWarmOptions,
75
  type CacheWarmProgress,
76
  type CacheWrapOptions,
77
  type CacheWriteBehindOptions,
78
  type CacheWriteOptions,
79
  type InvalidationMessage,
80
  type LayerTtlMap
81
} from './types'
82

83
const DEFAULT_SINGLE_FLIGHT_LEASE_MS = 30_000
10✔
84
const DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5_000
10✔
85
const DEFAULT_SINGLE_FLIGHT_POLL_MS = 50
10✔
86
const DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 30_000
10✔
87
const DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1_024 * 1_024
10✔
88
const DEFAULT_SNAPSHOT_MAX_ENTRIES = 10_000
10✔
89
const DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50
10✔
90
const DEFAULT_INVALIDATION_MAX_KEYS = 10_000
10✔
91
const DEFAULT_MAX_PROFILE_ENTRIES = 100_000
10✔
92

93
type ReadMode = 'allow-stale' | 'fresh-only'
94
type CacheWriteKind = 'value' | 'empty'
95

96
type ReadHit<T> =
97
  | {
98
      found: true
99
      value: T | null
100
      stored: unknown
101
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
102
      layerIndex: number
103
      layerName: string
104
    }
105
  | { found: false; value: null; stored: null; state: 'miss' }
106

107
class DebugLogger implements CacheLogger {
108
  private readonly enabled: boolean
109

110
  constructor(enabled: boolean) {
111
    this.enabled = enabled
196✔
112
  }
113

114
  debug(message: string, context?: Record<string, unknown>): void {
115
    this.write('debug', message, context)
587✔
116
  }
117

118
  info(message: string, context?: Record<string, unknown>): void {
119
    this.write('info', message, context)
3✔
120
  }
121

122
  warn(message: string, context?: Record<string, unknown>): void {
123
    this.write('warn', message, context)
39✔
124
  }
125

126
  error(message: string, context?: Record<string, unknown>): void {
127
    this.write('error', message, context)
17✔
128
  }
129

130
  private write(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
131
    if (!this.enabled) {
646✔
132
      return
644✔
133
    }
134

135
    const suffix = context ? ` ${JSON.stringify(context)}` : ''
2✔
136
    console[level](`[layercache] ${message}${suffix}`)
646✔
137
  }
138
}
139

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

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

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

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

181
    this.validateConfiguration()
207✔
182

183
    const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES
207✔
184
    this.ttlResolver = new TtlResolver({ maxProfileEntries })
208✔
185
    this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries })
208✔
186
    this.currentGeneration = options.generation
208✔
187

188
    if (options.publishSetInvalidation !== undefined) {
208✔
189
      console.warn(
1✔
190
        '[layercache] CacheStackOptions.publishSetInvalidation is deprecated. ' + 'Use broadcastL1Invalidation instead.'
191
      )
192
    }
193

194
    const debugEnv = process.env.DEBUG?.split(',').includes('layercache:debug') ?? false
203✔
195
    this.logger =
208✔
196
      typeof options.logger === 'object' ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv)
593✔
197
    this.tagIndex = options.tagIndex ?? new TagIndex()
208✔
198
    this.keyDiscovery = new CacheKeyDiscovery({
208✔
199
      layers: this.layers,
200
      tagIndex: this.tagIndex,
201
      shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
16✔
202
      handleLayerFailure: async (layer, operation, error) => {
203
        await this.handleLayerFailure(layer, operation, error)
1✔
204
      }
205
    })
206
    if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
233✔
207
      this.logger.warn?.(
20✔
208
        '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.'
209
      )
210
    }
211
    if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
233✔
212
      this.logger.warn?.(
4✔
213
        '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.'
214
      )
215
    }
216
    if (
203✔
217
      options.invalidationBus &&
231✔
218
      options.broadcastL1Invalidation === undefined &&
219
      options.publishSetInvalidation === undefined
220
    ) {
221
      this.logger.warn?.(
12✔
222
        'broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired.'
223
      )
224
    }
225
    this.initializeWriteBehind(options.writeBehind)
203✔
226
    this.startup = this.initialize()
203✔
227
  }
228

229
  /**
230
   * Read-through cache get.
231
   * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
232
   * and stores the result across all layers. Returns `null` if the key is not found
233
   * and no `fetcher` is provided.
234
   */
235
  async get<T>(key: string, fetcher?: () => Promise<T>, options?: CacheGetOptions): Promise<T | null> {
236
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
240✔
237
    this.validateWriteOptions(options)
240✔
238
    await this.awaitStartup('get')
240✔
239
    return this.getPrepared(normalizedKey, fetcher, options)
234✔
240
  }
241

242
  private async getPrepared<T>(
243
    normalizedKey: string,
244
    fetcher?: () => Promise<T>,
245
    options?: CacheGetOptions
246
  ): Promise<T | null> {
247
    const hit = await this.readFromLayers<T>(normalizedKey, options, 'allow-stale')
236✔
248
    if (hit.found) {
236✔
249
      this.ttlResolver.recordAccess(normalizedKey)
79✔
250
      if (this.isNegativeStoredValue(hit.stored)) {
79✔
251
        this.metricsCollector.increment('negativeCacheHits')
1✔
252
      }
253

254
      if (hit.state === 'fresh') {
79✔
255
        this.metricsCollector.increment('hits')
66✔
256
        await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher)
66✔
257
        return hit.value
66✔
258
      }
259

260
      if (hit.state === 'stale-while-revalidate') {
13✔
261
        this.metricsCollector.increment('hits')
10✔
262
        this.metricsCollector.increment('staleHits')
10✔
263
        this.emit('stale-serve', { key: normalizedKey, state: hit.state, layer: hit.layerName })
10✔
264
        if (fetcher) {
10!
265
          this.scheduleBackgroundRefresh(normalizedKey, fetcher, options)
10✔
266
        }
267
        return hit.value
10✔
268
      }
269

270
      if (!fetcher) {
3✔
271
        this.metricsCollector.increment('hits')
1✔
272
        this.metricsCollector.increment('staleHits')
1✔
273
        this.emit('stale-serve', { key: normalizedKey, state: hit.state, layer: hit.layerName })
1✔
274
        return hit.value
1✔
275
      }
276

277
      try {
2✔
278
        return await this.fetchWithGuards(normalizedKey, fetcher, options)
2✔
279
      } catch (error) {
280
        this.metricsCollector.increment('staleHits')
2✔
281
        this.metricsCollector.increment('refreshErrors')
2✔
282
        this.logger.debug?.('stale-if-error', { key: normalizedKey, error: this.formatError(error) })
2✔
283
        return hit.value
2✔
284
      }
285
    }
286

287
    this.metricsCollector.increment('misses')
157✔
288
    if (!fetcher) {
157✔
289
      return null
55✔
290
    }
291

292
    return this.fetchWithGuards(normalizedKey, fetcher, options)
102✔
293
  }
294

295
  /**
296
   * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
297
   * Fetches and caches the value if not already present.
298
   */
299
  async getOrSet<T>(key: string, fetcher: () => Promise<T>, options?: CacheGetOptions): Promise<T | null> {
300
    return this.get(key, fetcher, options)
3✔
301
  }
302

303
  /**
304
   * Like `get()`, but throws `CacheMissError` instead of returning `null`.
305
   * Useful when the value is expected to exist or the fetcher is expected to
306
   * return non-null.
307
   */
308
  async getOrThrow<T>(key: string, fetcher?: () => Promise<T>, options?: CacheGetOptions): Promise<T> {
309
    const value = await this.get(key, fetcher, options)
4✔
310
    if (value === null) {
4✔
311
      throw new CacheMissError(key)
3✔
312
    }
313
    return value
1✔
314
  }
315

316
  /**
317
   * Returns true if the given key exists and is not expired in any layer.
318
   */
319
  async has(key: string): Promise<boolean> {
320
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
8✔
321
    await this.awaitStartup('has')
8✔
322

323
    for (const layer of this.layers) {
8✔
324
      if (this.shouldSkipLayer(layer)) {
15!
325
        continue
×
326
      }
327
      if (layer.has) {
15✔
328
        try {
5✔
329
          const exists = await layer.has(normalizedKey)
5✔
330
          if (exists) {
4✔
331
            return true
2✔
332
          }
333
        } catch {
334
          await this.reportRecoverableLayerFailure(layer, 'has', new Error(`has() failed for layer "${layer.name}"`))
1✔
335
          // fall through to next layer
336
        }
337
      } else {
338
        try {
10✔
339
          const value = await layer.get(normalizedKey)
10✔
340
          if (value !== null) {
7✔
341
            return true
2✔
342
          }
343
        } catch (error) {
344
          await this.reportRecoverableLayerFailure(layer, 'has', error)
3✔
345
          // fall through
346
        }
347
      }
348
    }
349
    return false
4✔
350
  }
351

352
  /**
353
   * Returns the remaining TTL in seconds for the key in the fastest layer
354
   * that has it, or null if the key is not found / has no TTL.
355
   */
356
  async ttl(key: string): Promise<number | null> {
357
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
4✔
358
    await this.awaitStartup('ttl')
4✔
359

360
    for (const layer of this.layers) {
4✔
361
      if (this.shouldSkipLayer(layer)) {
8✔
362
        continue
1✔
363
      }
364
      if (layer.ttl) {
7✔
365
        try {
6✔
366
          const remaining = await layer.ttl(normalizedKey)
6✔
367
          if (remaining !== null) {
4✔
368
            return remaining
3✔
369
          }
370
        } catch {
371
          // fall through
372
        }
373
      }
374
    }
375
    return null
1✔
376
  }
377

378
  /**
379
   * Stores a value in all cache layers. Overwrites any existing value.
380
   */
381
  async set<T>(key: string, value: T, options?: CacheWriteOptions): Promise<void> {
382
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
100✔
383
    this.validateWriteOptions(options)
100✔
384
    await this.awaitStartup('set')
100✔
385
    await this.storeEntry(normalizedKey, 'value', value, options)
96✔
386
  }
387

388
  /**
389
   * Deletes the key from all layers and publishes an invalidation message.
390
   */
391
  async delete(key: string): Promise<void> {
392
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
7✔
393
    await this.awaitStartup('delete')
7✔
394
    await this.deleteKeys([normalizedKey])
6✔
395
    await this.publishInvalidation({
6✔
396
      scope: 'key',
397
      keys: [normalizedKey],
398
      sourceId: this.instanceId,
399
      operation: 'delete'
400
    })
401
  }
402

403
  async clear(): Promise<void> {
404
    await this.awaitStartup('clear')
4✔
405
    this.maintenance.beginClearEpoch()
4✔
406
    await Promise.all(this.layers.map((layer) => layer.clear()))
5✔
407
    await this.tagIndex.clear()
4✔
408
    this.ttlResolver.clearProfiles()
4✔
409
    this.circuitBreakerManager.clear()
4✔
410
    this.metricsCollector.increment('invalidations')
4✔
411
    this.logger.debug?.('clear')
4✔
412
    await this.publishInvalidation({ scope: 'clear', sourceId: this.instanceId, operation: 'clear' })
4✔
413
  }
414

415
  /**
416
   * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
417
   */
418
  async mdelete(keys: string[]): Promise<void> {
419
    if (keys.length === 0) {
3✔
420
      return
1✔
421
    }
422
    await this.awaitStartup('mdelete')
2✔
423
    const normalizedKeys = keys.map((k) => validateCacheKey(k))
3✔
424
    const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key))
3✔
425
    await this.deleteKeys(cacheKeys)
2✔
426
    await this.publishInvalidation({
2✔
427
      scope: 'keys',
428
      keys: cacheKeys,
429
      sourceId: this.instanceId,
430
      operation: 'delete'
431
    })
432
  }
433

434
  async mget<T>(entries: CacheMGetEntry<T>[]): Promise<Array<T | null>> {
435
    this.assertActive('mget')
9✔
436
    if (entries.length === 0) {
9✔
437
      return []
1✔
438
    }
439

440
    const normalizedEntries = entries.map((entry) => ({
18✔
441
      ...entry,
442
      key: this.qualifyKey(validateCacheKey(entry.key))
443
    }))
444
    normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
18✔
445
    const canFastPath = normalizedEntries.every((entry) => entry.fetch === undefined && entry.options === undefined)
16✔
446
    if (!canFastPath) {
8✔
447
      await this.awaitStartup('mget')
2✔
448
      const pendingReads = new Map<
2✔
449
        string,
450
        {
451
          promise: Promise<T | null>
452
          fetch?: () => Promise<T>
453
          optionsSignature: string
454
        }
455
      >()
456

457
      return Promise.all(
2✔
458
        normalizedEntries.map((entry) => {
459
          const optionsSignature = serializeOptions(entry.options)
4✔
460
          const existing = pendingReads.get(entry.key)
4✔
461
          if (!existing) {
4✔
462
            const promise = this.getPrepared(entry.key, entry.fetch, entry.options)
2✔
463
            pendingReads.set(entry.key, {
2✔
464
              promise,
465
              fetch: entry.fetch,
466
              optionsSignature
467
            })
468
            return promise
2✔
469
          }
470

471
          if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2!
472
            throw new Error(`mget received conflicting entries for key "${entry.key}".`)
2✔
473
          }
474

475
          return existing.promise
×
476
        })
477
      )
478
    }
479

480
    await this.awaitStartup('mget')
6✔
481
    const pending = new Set<string>()
6✔
482
    const indexesByKey = new Map<string, number[]>()
6✔
483
    const resultsByKey = new Map<string, T | null>()
6✔
484

485
    for (let index = 0; index < normalizedEntries.length; index += 1) {
6✔
486
      const entry = normalizedEntries[index]
14✔
487
      if (!entry) continue
14!
488
      const key = entry.key
14✔
489
      const indexes = indexesByKey.get(key) ?? []
14✔
490
      indexes.push(index)
14✔
491
      indexesByKey.set(key, indexes)
14✔
492
      pending.add(key)
14✔
493
    }
494

495
    for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
6✔
496
      const layer = this.layers[layerIndex]
6✔
497
      if (!layer) continue
6!
498
      const keys = [...pending]
6✔
499
      if (keys.length === 0) {
6!
500
        break
×
501
      }
502

503
      const values = layer.getMany
6!
504
        ? await layer.getMany(keys)
505
        : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)))
×
506

507
      for (let offset = 0; offset < values.length; offset += 1) {
×
508
        const key = keys[offset]
13✔
509
        const stored = values[offset]
13✔
510
        if (!key || stored === null) {
13✔
511
          continue
2✔
512
        }
513

514
        const resolved = resolveStoredValue<T>(stored)
11✔
515
        if (resolved.state === 'expired') {
11✔
516
          await layer.delete(key)
1✔
517
          continue
1✔
518
        }
519

520
        await this.tagIndex.touch(key)
10✔
521
        await this.backfill(key, stored, layerIndex - 1)
10✔
522
        resultsByKey.set(key, resolved.value)
10✔
523
        pending.delete(key)
10✔
524
        this.metricsCollector.increment('hits', indexesByKey.get(key)?.length ?? 1)
10!
525
      }
526
    }
527

528
    if (pending.size > 0) {
6✔
529
      for (const key of pending) {
2✔
530
        await this.tagIndex.remove(key)
3✔
531
        this.metricsCollector.increment('misses', indexesByKey.get(key)?.length ?? 1)
3!
532
      }
533
    }
534

535
    return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null)
14✔
536
  }
537

538
  async mset<T>(entries: CacheMSetEntry<T>[]): Promise<void> {
539
    this.assertActive('mset')
6✔
540
    const normalizedEntries = entries.map((entry) => ({
13✔
541
      ...entry,
542
      key: this.qualifyKey(validateCacheKey(entry.key))
543
    }))
544
    normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
13✔
545
    await this.awaitStartup('mset')
6✔
546
    await this.writeBatch(normalizedEntries)
6✔
547
  }
548

549
  async warm(entries: CacheWarmEntry[], options: CacheWarmOptions = {}): Promise<void> {
4✔
550
    this.assertActive('warm')
4✔
551
    const concurrency = Math.max(1, options.concurrency ?? 4)
4✔
552
    const total = entries.length
4✔
553
    let completed = 0
4✔
554
    const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
4!
555
    const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
4!
556
      while (queue.length > 0) {
4✔
557
        const entry = queue.shift()
6✔
558
        if (!entry) {
6!
559
          return
×
560
        }
561

562
        let success = false
6✔
563
        try {
6✔
564
          await this.get(entry.key, entry.fetcher, entry.options)
6✔
565
          this.emit('warm', { key: entry.key })
4✔
566
          success = true
4✔
567
        } catch (error) {
568
          this.emitError('warm', { key: entry.key, error: this.formatError(error) })
2✔
569
          if (!options.continueOnError) {
2✔
570
            throw error
1✔
571
          }
572
        } finally {
573
          completed += 1
6✔
574
          const progress: CacheWarmProgress = { completed, total, key: entry.key, success }
6✔
575
          options.onProgress?.(progress)
6✔
576
        }
577
      }
578
    })
579

580
    await Promise.all(workers)
4✔
581
  }
582

583
  /**
584
   * Returns a cached version of `fetcher`. The cache key is derived from
585
   * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
586
   */
587
  wrap<TArgs extends unknown[], TResult>(
588
    prefix: string,
589
    fetcher: (...args: TArgs) => Promise<TResult>,
590
    options: CacheWrapOptions<TArgs> = {}
9✔
591
  ): (...args: TArgs) => Promise<TResult | null> {
592
    return (...args: TArgs) => {
9✔
593
      const suffix = options.keyResolver
16✔
594
        ? options.keyResolver(...args)
595
        : args.map((argument) => serializeKeyPart(argument)).join(':')
9✔
596
      const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix
16!
597
      return this.get<TResult>(key, () => fetcher(...args), options)
16✔
598
    }
599
  }
600

601
  /**
602
   * Creates a `CacheNamespace` that automatically prefixes all keys with
603
   * `prefix:`. Useful for multi-tenant or module-level isolation.
604
   */
605
  namespace(prefix: string): CacheNamespace {
606
    validateNamespaceKey(prefix)
36✔
607
    return new CacheNamespace(this, prefix)
36✔
608
  }
609

610
  async invalidateByTag(tag: string): Promise<void> {
611
    validateTag(tag)
8✔
612
    await this.awaitStartup('invalidateByTag')
8✔
613
    const keys = await this.collectKeysForTag(tag)
7✔
614
    await this.deleteKeys(keys)
6✔
615
    await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
616
  }
617

618
  async invalidateByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
4✔
619
    if (tags.length === 0) {
4!
620
      return
×
621
    }
622

623
    validateTags(tags)
4✔
624
    await this.awaitStartup('invalidateByTags')
4✔
625
    const keysByTag = await Promise.all(tags.map((tag) => this.collectKeysForTag(tag)))
7✔
626
    const keys = mode === 'all' ? this.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
3✔
627
    this.assertWithinInvalidationKeyLimit(keys.length)
4✔
628

629
    await this.deleteKeys(keys)
4✔
630
    await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
631
  }
632

633
  async invalidateByPattern(pattern: string): Promise<void> {
634
    validatePattern(pattern)
5✔
635
    await this.awaitStartup('invalidateByPattern')
5✔
636
    const keys = await this.keyDiscovery.collectKeysMatchingPattern(
5✔
637
      this.qualifyPattern(pattern),
638
      this.invalidationMaxKeys()
639
    )
640
    await this.deleteKeys(keys)
4✔
641
    await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
4✔
642
  }
643

644
  async invalidateByPrefix(prefix: string): Promise<void> {
645
    await this.awaitStartup('invalidateByPrefix')
7✔
646
    const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
7✔
647
    const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
7✔
648
    await this.deleteKeys(keys)
6✔
649
    await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
650
  }
651

652
  getMetrics(): CacheMetricsSnapshot {
653
    return this.metricsCollector.snapshot
185✔
654
  }
655

656
  getStats(): CacheStatsSnapshot {
657
    return {
26✔
658
      metrics: this.getMetrics(),
659
      layers: this.layers.map((layer) => ({
29✔
660
        name: layer.name,
661
        isLocal: Boolean(layer.isLocal),
662
        degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
55✔
663
      })),
664
      backgroundRefreshes: this.backgroundRefreshes.size
665
    }
666
  }
667

668
  resetMetrics(): void {
669
    this.metricsCollector.reset()
×
670
  }
671

672
  /**
673
   * Returns computed hit-rate statistics (overall and per-layer).
674
   */
675
  getHitRate(): CacheHitRateSnapshot {
676
    return this.metricsCollector.hitRate()
6✔
677
  }
678

679
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
680
    await this.startup
2✔
681

682
    return Promise.all(
2✔
683
      this.layers.map(async (layer) => {
684
        const startedAt = performance.now()
4✔
685
        try {
4✔
686
          const healthy = layer.ping ? await layer.ping() : true
4✔
687
          return {
4✔
688
            layer: layer.name,
689
            healthy,
690
            latencyMs: performance.now() - startedAt
691
          }
692
        } catch (error) {
693
          return {
1✔
694
            layer: layer.name,
695
            healthy: false,
696
            latencyMs: performance.now() - startedAt,
697
            error: this.formatError(error)
698
          }
699
        }
700
      })
701
    )
702
  }
703

704
  /**
705
   * Rotates the active generation prefix used for all future cache keys.
706
   * Previous-generation keys remain in the underlying layers until they expire,
707
   * unless `generationCleanup` is enabled to prune them in the background.
708
   */
709
  bumpGeneration(nextGeneration?: number): number {
710
    const current = this.currentGeneration ?? 0
3!
711
    const previousGeneration = this.currentGeneration
3✔
712
    const updatedGeneration = nextGeneration ?? current + 1
3✔
713
    const generationToCleanup = resolveGenerationCleanupTarget({
3✔
714
      previousGeneration,
715
      nextGeneration: updatedGeneration,
716
      generationCleanup: this.options.generationCleanup
717
    })
718

719
    this.currentGeneration = updatedGeneration
3✔
720
    if (generationToCleanup !== null) {
3✔
721
      this.scheduleGenerationCleanup(generationToCleanup)
2✔
722
    }
723

724
    return this.currentGeneration
3✔
725
  }
726

727
  /**
728
   * Returns detailed metadata about a single cache key: which layers contain it,
729
   * remaining fresh/stale/error TTLs, and associated tags.
730
   * Returns `null` if the key does not exist in any layer.
731
   */
732
  async inspect(key: string): Promise<CacheInspectResult | null> {
733
    const userKey = validateCacheKey(key)
3✔
734
    const normalizedKey = this.qualifyKey(userKey)
3✔
735
    await this.awaitStartup('inspect')
3✔
736

737
    const foundInLayers: string[] = []
3✔
738
    let freshTtlSeconds: number | null = null
3✔
739
    let staleTtlSeconds: number | null = null
3✔
740
    let errorTtlSeconds: number | null = null
3✔
741
    let isStale = false
3✔
742

743
    for (const layer of this.layers) {
3✔
744
      if (this.shouldSkipLayer(layer)) {
3!
745
        continue
×
746
      }
747
      const stored = await this.readLayerEntry(layer, normalizedKey)
3✔
748
      if (stored === null) {
3✔
749
        continue
1✔
750
      }
751

752
      const resolved = resolveStoredValue(stored)
2✔
753
      if (resolved.state === 'expired') {
2!
754
        continue
×
755
      }
756

757
      foundInLayers.push(layer.name)
2✔
758

759
      // Take TTL info from the first (fastest) layer that has it
760
      if (foundInLayers.length === 1 && resolved.envelope) {
2!
761
        const now = Date.now()
2✔
762
        freshTtlSeconds =
2✔
763
          resolved.envelope.freshUntil !== null
2!
764
            ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1_000))
765
            : null
766
        staleTtlSeconds =
2✔
767
          resolved.envelope.staleUntil !== null
2✔
768
            ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1_000))
769
            : null
770
        errorTtlSeconds =
2✔
771
          resolved.envelope.errorUntil !== null
2✔
772
            ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1_000))
773
            : null
774
        isStale = resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error'
2✔
775
      }
776
    }
777

778
    if (foundInLayers.length === 0) {
3✔
779
      return null
1✔
780
    }
781

782
    const tags = await this.getTagsForKey(normalizedKey)
2✔
783

784
    return { key: userKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags }
2✔
785
  }
786

787
  async exportState(): Promise<CacheSnapshotEntry[]> {
788
    await this.awaitStartup('exportState')
2✔
789
    const entries: CacheSnapshotEntry[] = []
2✔
790
    await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
2✔
791
      entries.push(entry)
2✔
792
    })
793
    return entries
1✔
794
  }
795

796
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
797
    await this.awaitStartup('importState')
4✔
798
    const normalizedEntries = entries.map((entry) => ({
4✔
799
      key: this.qualifyKey(validateCacheKey(entry.key)),
800
      value: entry.value,
801
      ttl: entry.ttl
802
    }))
803

804
    for (let index = 0; index < normalizedEntries.length; index += DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE) {
4✔
805
      const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE)
3✔
806
      await Promise.all(
3✔
807
        batch.map(async (entry) => {
808
          await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)))
3✔
809
          await this.tagIndex.touch(entry.key)
3✔
810
        })
811
      )
812
    }
813
  }
814

815
  async persistToFile(filePath: string): Promise<void> {
816
    this.assertActive('persistToFile')
4✔
817
    const { promises: fs } = await import('node:fs')
4✔
818
    const path = await import('node:path')
4✔
819
    const targetPath = await validateSnapshotFilePath(filePath, 'write', this.options.snapshotBaseDir)
4✔
820
    const tempPath = path.join(
2✔
821
      path.dirname(targetPath),
822
      `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
823
    )
824
    let handle: import('node:fs/promises').FileHandle | undefined
825

826
    try {
2✔
827
      handle = await fs.open(tempPath, 'wx')
2✔
828
      const openedHandle = handle
2✔
829
      await openedHandle.writeFile('[', 'utf8')
2✔
830

831
      let wroteAny = false
2✔
832
      await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
2✔
833
        await openedHandle.writeFile(wroteAny ? ',\n' : '\n', 'utf8')
1!
834
        await openedHandle.writeFile(JSON.stringify(entry, null, 2), 'utf8')
1✔
835
        wroteAny = true
1✔
836
      })
837

838
      await openedHandle.writeFile(wroteAny ? '\n]' : ']', 'utf8')
2✔
839
      await openedHandle.close()
2✔
840
      handle = undefined
2✔
841
      await fs.rename(tempPath, targetPath)
2✔
842
    } catch (error) {
843
      await handle?.close().catch(() => undefined)
×
844
      await fs.unlink(tempPath).catch(() => undefined)
×
845
      throw error
×
846
    }
847
  }
848

849
  async restoreFromFile(filePath: string): Promise<void> {
850
    this.assertActive('restoreFromFile')
9✔
851
    const { promises: fs, constants } = await import('node:fs')
9✔
852
    const validatedPath = await validateSnapshotFilePath(filePath, 'read', this.options.snapshotBaseDir)
9✔
853
    const handle = await fs.open(validatedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
7!
854
    const snapshotMaxBytes = this.snapshotMaxBytes()
7✔
855
    let raw: string
856
    try {
7✔
857
      if (snapshotMaxBytes !== false) {
7!
858
        const stat = await handle.stat()
7✔
859
        if (stat.size > snapshotMaxBytes) {
7✔
860
          throw new Error(
2✔
861
            `Snapshot file exceeds snapshotMaxBytes limit (${stat.size} bytes > ${snapshotMaxBytes} bytes).`
862
          )
863
        }
864
      }
865

866
      raw = await readUtf8HandleWithLimit(handle, snapshotMaxBytes)
5✔
867
    } finally {
868
      await handle.close()
7✔
869
    }
870

871
    let parsed: unknown
872
    try {
5✔
873
      parsed = JSON.parse(raw)
5✔
874
    } catch (cause) {
875
      throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`)
×
876
    }
877
    if (!this.isCacheSnapshotEntries(parsed)) {
5✔
878
      throw new Error('Invalid snapshot file: expected an array of { key: string, value, ttl? } entries')
1✔
879
    }
880
    await this.importState(
4✔
881
      parsed.map((entry) => ({
4✔
882
        key: entry.key,
883
        value: this.sanitizeSnapshotValue(entry.value),
884
        ttl: entry.ttl
885
      }))
886
    )
887
  }
888

889
  async disconnect(): Promise<void> {
890
    if (!this.disconnectPromise) {
26!
891
      this.isDisconnecting = true
26✔
892
      this.disconnectPromise = (async () => {
26✔
893
        await this.startup
26✔
894
        await this.unsubscribeInvalidation?.()
26✔
895
        await this.flushWriteBehindQueue()
26✔
896
        await this.maintenance.waitForGenerationCleanup()
26✔
897
        await Promise.allSettled([...this.backgroundRefreshes.values()])
26✔
898
        this.maintenance.disposeWriteBehindTimer()
26✔
899
        await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()))
38✔
900
      })()
901
    }
902

903
    await this.disconnectPromise
26✔
904
  }
905

906
  private async initialize(): Promise<void> {
907
    if (!this.options.invalidationBus) {
203✔
908
      return
188✔
909
    }
910

911
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
15✔
912
      await this.handleInvalidationMessage(message)
10✔
913
    })
914
  }
915

916
  private async fetchWithGuards<T>(
917
    key: string,
918
    fetcher: () => Promise<T>,
919
    options?: CacheGetOptions,
920
    expectedClearEpoch?: number,
921
    expectedKeyEpoch?: number
922
  ): Promise<T | null> {
923
    const fetchTask = async (): Promise<T | null> => {
113✔
924
      const secondHit = await this.readFromLayers<T>(key, options, 'fresh-only')
111✔
925
      if (secondHit.found) {
111✔
926
        this.metricsCollector.increment('hits')
50✔
927
        return secondHit.value
50✔
928
      }
929

930
      return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
61✔
931
    }
932

933
    const singleFlightTask = async (): Promise<T | null> => {
113✔
934
      if (!this.options.singleFlightCoordinator) {
113✔
935
        return fetchTask()
110✔
936
      }
937

938
      return this.options.singleFlightCoordinator.execute(key, this.resolveSingleFlightOptions(), fetchTask, () =>
3✔
939
        this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
2✔
940
      )
941
    }
942

943
    if (this.options.stampedePrevention === false) {
113✔
944
      return singleFlightTask()
2✔
945
    }
946

947
    return this.stampedeGuard.execute(key, singleFlightTask)
111✔
948
  }
949

950
  private async waitForFreshValue<T>(
951
    key: string,
952
    fetcher: () => Promise<T>,
953
    options?: CacheGetOptions,
954
    expectedClearEpoch?: number,
955
    expectedKeyEpoch?: number
956
  ): Promise<T | null> {
957
    const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS
2✔
958
    const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
2✔
959
    const deadline = Date.now() + timeoutMs
2✔
960

961
    this.metricsCollector.increment('singleFlightWaits')
2✔
962
    this.emit('stampede-dedupe', { key })
2✔
963

964
    while (Date.now() < deadline) {
2✔
965
      const hit = await this.readFromLayers<T>(key, options, 'fresh-only')
6✔
966
      if (hit.found) {
6✔
967
        this.metricsCollector.increment('hits')
1✔
968
        return hit.value
1✔
969
      }
970
      await this.sleep(pollIntervalMs)
5✔
971
    }
972

973
    return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
1✔
974
  }
975

976
  private async fetchAndPopulate<T>(
977
    key: string,
978
    fetcher: () => Promise<T>,
979
    options?: CacheGetOptions,
980
    expectedClearEpoch?: number,
981
    expectedKeyEpoch?: number
982
  ): Promise<T | null> {
983
    this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker)
62✔
984
    this.metricsCollector.increment('fetches')
62✔
985
    const fetchStart = Date.now()
62✔
986
    let fetched: T
987

988
    try {
62✔
989
      fetched = await this.fetchRateLimiter.schedule(
62✔
990
        options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
115✔
991
        { key, fetcher },
992
        fetcher
993
      )
994
      this.circuitBreakerManager.recordSuccess(key)
47✔
995
      this.logger.debug?.('fetch', { key, durationMs: Date.now() - fetchStart })
47✔
996
    } catch (error) {
997
      this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error)
12✔
998
      throw error
12✔
999
    }
1000

1001
    if (fetched === null || fetched === undefined) {
47✔
1002
      if (!this.shouldNegativeCache(options)) {
5✔
1003
        return null
3✔
1004
      }
1005

1006
      if (this.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
2✔
1007
        this.logger.debug?.('skip-negative-store-after-invalidation', {
1✔
1008
          key,
1009
          expectedClearEpoch,
1010
          clearEpoch: this.maintenance.currentClearEpoch(),
1011
          expectedKeyEpoch,
1012
          keyEpoch: this.maintenance.currentKeyEpoch(key)
1013
        })
1014
        return null
1✔
1015
      }
1016

1017
      await this.storeEntry(key, 'empty', null, options)
1✔
1018
      return null
1✔
1019
    }
1020

1021
    // Conditional caching: skip storage if shouldCache returns false
1022
    if (options?.shouldCache) {
42✔
1023
      try {
2✔
1024
        if (!options.shouldCache(fetched)) {
2✔
1025
          return fetched
1✔
1026
        }
1027
      } catch (error) {
1028
        this.logger.warn?.('shouldCache-error', { key, error: this.formatError(error) })
1✔
1029
      }
1030
    }
1031

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

1043
    await this.storeEntry(key, 'value', fetched, options)
39✔
1044
    return fetched
39✔
1045
  }
1046

1047
  private async storeEntry(
1048
    key: string,
1049
    kind: CacheWriteKind,
1050
    value: unknown,
1051
    options?: CacheWriteOptions
1052
  ): Promise<void> {
1053
    const clearEpoch = this.maintenance.currentClearEpoch()
136✔
1054
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
136✔
1055
    await this.writeAcrossLayers(key, kind, value, options)
136✔
1056
    if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
136!
1057
      return
×
1058
    }
1059
    if (options?.tags) {
136✔
1060
      await this.tagIndex.track(key, options.tags)
20✔
1061
    } else {
1062
      await this.tagIndex.touch(key)
116✔
1063
    }
1064

1065
    this.metricsCollector.increment('sets')
136✔
1066
    this.logger.debug?.('set', { key, kind, tags: options?.tags })
136✔
1067
    this.emit('set', { key, kind: kind as string, tags: options?.tags })
136✔
1068
    if (this.shouldBroadcastL1Invalidation()) {
136✔
1069
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
1070
    }
1071
  }
1072

1073
  private async writeBatch(
1074
    entries: Array<{ key: string; value: unknown; options?: CacheWriteOptions }>
1075
  ): Promise<void> {
1076
    const now = Date.now()
6✔
1077
    const clearEpoch = this.maintenance.currentClearEpoch()
6✔
1078
    const entryEpochs = new Map(entries.map((entry) => [entry.key, this.maintenance.currentKeyEpoch(entry.key)]))
13✔
1079
    const entriesByLayer = new Map<CacheLayer, CacheLayerSetManyEntry[]>()
6✔
1080
    const immediateOperations: Array<() => Promise<void>> = []
6✔
1081
    const deferredOperations: Array<() => Promise<void>> = []
6✔
1082

1083
    for (const entry of entries) {
6✔
1084
      for (const layer of this.layers) {
13✔
1085
        if (this.shouldSkipLayer(layer)) {
13!
1086
          continue
×
1087
        }
1088

1089
        const layerEntry = this.buildLayerSetEntry(layer, entry.key, 'value', entry.value, entry.options, now)
13✔
1090
        const bucket = entriesByLayer.get(layer) ?? []
13✔
1091
        bucket.push(layerEntry)
13✔
1092
        entriesByLayer.set(layer, bucket)
13✔
1093
      }
1094
    }
1095

1096
    for (const [layer, layerEntries] of entriesByLayer.entries()) {
6✔
1097
      const operation = async () => {
6✔
1098
        if (clearEpoch !== this.maintenance.currentClearEpoch()) {
6!
1099
          return
×
1100
        }
1101
        const activeEntries = layerEntries.filter(
6✔
1102
          (entry) => (entryEpochs.get(entry.key) ?? 0) === this.maintenance.currentKeyEpoch(entry.key)
13!
1103
        )
1104
        if (activeEntries.length === 0) {
6!
1105
          return
×
1106
        }
1107
        try {
6✔
1108
          if (layer.setMany) {
6!
1109
            await layer.setMany(activeEntries)
6✔
1110
            return
6✔
1111
          }
1112

1113
          await Promise.all(activeEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)))
×
1114
        } catch (error) {
1115
          await this.handleLayerFailure(layer, 'write', error)
×
1116
        }
1117
      }
1118

1119
      if (this.shouldWriteBehind(layer)) {
6!
1120
        deferredOperations.push(operation)
×
1121
      } else {
1122
        immediateOperations.push(operation)
6✔
1123
      }
1124
    }
1125

1126
    await this.executeLayerOperations(immediateOperations, { key: 'batch', action: 'mset' })
6✔
1127
    await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)))
6✔
1128
    if (clearEpoch !== this.maintenance.currentClearEpoch()) {
6!
1129
      return
×
1130
    }
1131

1132
    for (const entry of entries) {
6✔
1133
      if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
13!
1134
        continue
×
1135
      }
1136
      if (entry.options?.tags) {
13!
1137
        await this.tagIndex.track(entry.key, entry.options.tags)
×
1138
      } else {
1139
        await this.tagIndex.touch(entry.key)
13✔
1140
      }
1141

1142
      this.metricsCollector.increment('sets')
13✔
1143
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
13✔
1144
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
13✔
1145
    }
1146

1147
    if (this.shouldBroadcastL1Invalidation()) {
6!
1148
      await this.publishInvalidation({
×
1149
        scope: 'keys',
1150
        keys: entries.map((entry) => entry.key),
×
1151
        sourceId: this.instanceId,
1152
        operation: 'write'
1153
      })
1154
    }
1155
  }
1156

1157
  private async readFromLayers<T>(
1158
    key: string,
1159
    options: CacheGetOptions | undefined,
1160
    mode: ReadMode
1161
  ): Promise<ReadHit<T>> {
1162
    let sawRetainableValue = false
353✔
1163

1164
    for (let index = 0; index < this.layers.length; index += 1) {
353✔
1165
      const layer = this.layers[index]
373✔
1166
      if (!layer) continue
373!
1167
      const readStart = performance.now()
373✔
1168
      const stored = await this.readLayerEntry(layer, key)
373✔
1169
      const readDuration = performance.now() - readStart
373✔
1170
      this.metricsCollector.recordLatency(layer.name, readDuration)
373✔
1171
      if (stored === null) {
373✔
1172
        this.metricsCollector.incrementLayer('missesByLayer', layer.name)
232✔
1173
        continue
232✔
1174
      }
1175

1176
      const resolved = resolveStoredValue<T>(stored)
141✔
1177
      if (resolved.state === 'expired') {
141!
1178
        await layer.delete(key)
×
1179
        continue
×
1180
      }
1181

1182
      sawRetainableValue = true
141✔
1183

1184
      if (mode === 'fresh-only' && resolved.state !== 'fresh') {
141✔
1185
        continue
11✔
1186
      }
1187

1188
      await this.tagIndex.touch(key)
130✔
1189
      await this.backfill(key, stored, index - 1, options)
130✔
1190
      this.metricsCollector.incrementLayer('hitsByLayer', layer.name)
130✔
1191
      this.logger.debug?.('hit', { key, layer: layer.name, state: resolved.state })
130✔
1192
      this.emit('hit', { key, layer: layer.name, state: resolved.state as CacheStackEvents['hit']['state'] })
373✔
1193
      return {
373✔
1194
        found: true,
1195
        value: resolved.value,
1196
        stored,
1197
        state: resolved.state,
1198
        layerIndex: index,
1199
        layerName: layer.name
1200
      }
1201
    }
1202

1203
    if (!sawRetainableValue) {
223✔
1204
      await this.tagIndex.remove(key)
212✔
1205
    }
1206

1207
    this.logger.debug?.('miss', { key, mode })
223✔
1208
    this.emit('miss', { key, mode })
353✔
1209
    return { found: false, value: null, stored: null, state: 'miss' }
353✔
1210
  }
1211

1212
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1213
    if (this.shouldSkipLayer(layer)) {
381✔
1214
      return null
2✔
1215
    }
1216

1217
    if (layer.getEntry) {
379✔
1218
      try {
371✔
1219
        return await layer.getEntry(key)
371✔
1220
      } catch (error) {
1221
        return this.handleLayerFailure(layer, 'read', error)
1✔
1222
      }
1223
    }
1224

1225
    try {
8✔
1226
      return await layer.get(key)
8✔
1227
    } catch (error) {
1228
      return this.handleLayerFailure(layer, 'read', error)
2✔
1229
    }
1230
  }
1231

1232
  private async backfill(key: string, stored: unknown, upToIndex: number, options?: CacheGetOptions): Promise<void> {
1233
    if (upToIndex < 0) {
140✔
1234
      return
129✔
1235
    }
1236

1237
    for (let index = 0; index <= upToIndex; index += 1) {
11✔
1238
      const layer = this.layers[index]
11✔
1239
      if (!layer || this.shouldSkipLayer(layer)) {
11✔
1240
        continue
2✔
1241
      }
1242

1243
      const ttl =
9✔
1244
        remainingStoredTtlSeconds(stored) ??
1245
        this.resolveLayerSeconds(layer.name, options?.ttl, undefined, layer.defaultTtl)
1246
      try {
11✔
1247
        await layer.set(key, stored, ttl)
11✔
1248
      } catch (error) {
1249
        await this.handleLayerFailure(layer, 'backfill', error)
×
1250
        continue
×
1251
      }
1252
      this.metricsCollector.increment('backfills')
9✔
1253
      this.logger.debug?.('backfill', { key, layer: layer.name })
9✔
1254
      this.emit('backfill', { key, layer: layer.name })
11✔
1255
    }
1256
  }
1257

1258
  private async writeAcrossLayers(
1259
    key: string,
1260
    kind: CacheWriteKind,
1261
    value: unknown,
1262
    options?: CacheWriteOptions
1263
  ): Promise<void> {
1264
    const now = Date.now()
136✔
1265
    const clearEpoch = this.maintenance.currentClearEpoch()
136✔
1266
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
136✔
1267
    const immediateOperations: Array<() => Promise<void>> = []
136✔
1268
    const deferredOperations: Array<() => Promise<void>> = []
136✔
1269

1270
    for (const layer of this.layers) {
136✔
1271
      const operation = async () => {
155✔
1272
        if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
154!
1273
          return
×
1274
        }
1275
        if (this.shouldSkipLayer(layer)) {
154!
1276
          return
×
1277
        }
1278

1279
        const entry = this.buildLayerSetEntry(layer, key, kind, value, options, now)
154✔
1280
        try {
154✔
1281
          await layer.set(entry.key, entry.value, entry.ttl)
154✔
1282
        } catch (error) {
1283
          await this.handleLayerFailure(layer, 'write', error)
2✔
1284
        }
1285
      }
1286

1287
      if (this.shouldWriteBehind(layer)) {
155✔
1288
        deferredOperations.push(operation)
2✔
1289
      } else {
1290
        immediateOperations.push(operation)
153✔
1291
      }
1292
    }
1293

1294
    await this.executeLayerOperations(immediateOperations, { key, action: kind === 'empty' ? 'negative-set' : 'set' })
136✔
1295
    await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)))
136✔
1296
  }
1297

1298
  private async executeLayerOperations(
1299
    operations: Array<() => Promise<void>>,
1300
    context: { key: string; action: string }
1301
  ): Promise<void> {
1302
    if (this.options.writePolicy !== 'best-effort') {
142✔
1303
      await Promise.all(operations.map((operation) => operation()))
157✔
1304
      return
141✔
1305
    }
1306

1307
    const results = await Promise.allSettled(operations.map((operation) => operation()))
2✔
1308
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
2✔
1309
    if (failures.length === 0) {
1!
1310
      return
×
1311
    }
1312

1313
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1314
    this.logger.debug?.('write-failure', {
1✔
1315
      ...context,
1316
      failures: failures.map((failure) => this.formatError(failure.reason))
1✔
1317
    })
1318

1319
    if (failures.length === operations.length) {
142!
1320
      throw new AggregateError(
×
1321
        failures.map((failure) => failure.reason),
×
1322
        `${context.action} failed for every cache layer`
1323
      )
1324
    }
1325
  }
1326

1327
  private resolveFreshTtl(
1328
    key: string,
1329
    layerName: string,
1330
    kind: CacheWriteKind,
1331
    options: CacheWriteOptions | undefined,
1332
    fallbackTtl: number | undefined,
1333
    value: unknown
1334
  ): number | undefined {
1335
    return this.ttlResolver.resolveFreshTtl(
167✔
1336
      key,
1337
      layerName,
1338
      kind,
1339
      options,
1340
      fallbackTtl,
1341
      this.options.negativeTtl,
1342
      undefined,
1343
      value
1344
    )
1345
  }
1346

1347
  private resolveLayerSeconds(
1348
    layerName: string,
1349
    override: number | LayerTtlMap | undefined,
1350
    globalDefault?: number | LayerTtlMap,
1351
    fallback?: number
1352
  ): number | undefined {
1353
    return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback)
402✔
1354
  }
1355

1356
  private shouldNegativeCache(options?: CacheGetOptions): boolean {
1357
    return options?.negativeCache ?? this.options.negativeCaching ?? false
5✔
1358
  }
1359

1360
  private scheduleBackgroundRefresh<T>(key: string, fetcher: () => Promise<T>, options?: CacheGetOptions): void {
1361
    if (
10✔
1362
      !shouldStartBackgroundRefresh({
1363
        isDisconnecting: this.isDisconnecting,
1364
        hasRefreshInFlight: this.backgroundRefreshes.has(key)
1365
      })
1366
    ) {
1367
      return
1✔
1368
    }
1369

1370
    const clearEpoch = this.maintenance.currentClearEpoch()
9✔
1371
    const keyEpoch = this.maintenance.currentKeyEpoch(key)
9✔
1372
    const refresh = (async () => {
9✔
1373
      this.metricsCollector.increment('refreshes')
9✔
1374
      try {
9✔
1375
        await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch)
9✔
1376
      } catch (error) {
1377
        this.metricsCollector.increment('refreshErrors')
4✔
1378
        this.logger.debug?.('refresh-error', { key, error: this.formatError(error) })
4✔
1379
      } finally {
1380
        this.backgroundRefreshes.delete(key)
9✔
1381
      }
1382
    })()
1383

1384
    this.backgroundRefreshes.set(key, refresh)
9✔
1385
  }
1386

1387
  private async runBackgroundRefresh<T>(
1388
    key: string,
1389
    fetcher: () => Promise<T>,
1390
    options?: CacheGetOptions,
1391
    expectedClearEpoch?: number,
1392
    expectedKeyEpoch?: number
1393
  ): Promise<void> {
1394
    const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS
9✔
1395
    await this.fetchWithGuards(
9✔
1396
      key,
1397
      () =>
1398
        this.withTimeout(fetcher(), timeoutMs, () => {
9✔
1399
          return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`)
4✔
1400
        }),
1401
      options,
1402
      expectedClearEpoch,
1403
      expectedKeyEpoch
1404
    )
1405
  }
1406

1407
  private resolveSingleFlightOptions(): CacheSingleFlightExecutionOptions {
1408
    return {
3✔
1409
      leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
6✔
1410
      waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
5✔
1411
      pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
5✔
1412
      renewIntervalMs: this.options.singleFlightRenewIntervalMs
1413
    }
1414
  }
1415

1416
  private async deleteKeys(keys: string[]): Promise<void> {
1417
    if (keys.length === 0) {
28✔
1418
      return
4✔
1419
    }
1420

1421
    this.maintenance.bumpKeyEpochs(keys)
24✔
1422
    await this.deleteKeysFromLayers(this.layers, keys)
24✔
1423

1424
    for (const key of keys) {
24✔
1425
      await this.tagIndex.remove(key)
31✔
1426
      this.ttlResolver.deleteProfile(key)
31✔
1427
      this.circuitBreakerManager.delete(key)
31✔
1428
    }
1429

1430
    this.metricsCollector.increment('deletes', keys.length)
24✔
1431
    this.metricsCollector.increment('invalidations')
24✔
1432
    this.logger.debug?.('delete', { keys })
24✔
1433
    this.emit('delete', { keys })
28✔
1434
  }
1435

1436
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1437
    if (!this.options.invalidationBus) {
34✔
1438
      return
29✔
1439
    }
1440

1441
    await this.options.invalidationBus.publish(message)
5✔
1442
  }
1443

1444
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1445
    if (message.sourceId === this.instanceId) {
13✔
1446
      return
6✔
1447
    }
1448

1449
    const localLayers = this.layers.filter((layer) => layer.isLocal)
10✔
1450
    if (message.scope === 'clear') {
7✔
1451
      this.maintenance.beginClearEpoch()
2✔
1452
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1453
      await this.tagIndex.clear()
2✔
1454
      this.ttlResolver.clearProfiles()
2✔
1455
      this.circuitBreakerManager.clear()
2✔
1456
      return
2✔
1457
    }
1458

1459
    const keys = message.keys ?? []
5!
1460
    this.maintenance.bumpKeyEpochs(keys)
13✔
1461
    await this.deleteKeysFromLayers(localLayers, keys)
13✔
1462

1463
    if (message.operation !== 'write') {
5✔
1464
      for (const key of keys) {
2✔
1465
        await this.tagIndex.remove(key)
3✔
1466
        this.ttlResolver.deleteProfile(key)
3✔
1467
        this.circuitBreakerManager.delete(key)
3✔
1468
      }
1469
    }
1470
  }
1471

1472
  private async getTagsForKey(key: string): Promise<string[]> {
1473
    if (this.tagIndex.tagsForKey) {
3!
1474
      return this.tagIndex.tagsForKey(key)
3✔
1475
    }
1476
    return []
×
1477
  }
1478

1479
  private formatError(error: unknown): string {
1480
    if (error instanceof Error) {
37✔
1481
      return error.message
36✔
1482
    }
1483

1484
    return String(error)
1✔
1485
  }
1486

1487
  private sleep(ms: number): Promise<void> {
1488
    return new Promise((resolve) => setTimeout(resolve, ms))
5✔
1489
  }
1490

1491
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1492
    if (timeoutMs <= 0) {
11✔
1493
      return promise
1✔
1494
    }
1495

1496
    let timer: ReturnType<typeof setTimeout> | undefined
1497
    const observedPromise = promise.then(
10✔
1498
      (value) => ({ kind: 'value' as const, value }),
5✔
1499
      (error) => ({ kind: 'error' as const, error })
2✔
1500
    )
1501
    try {
10✔
1502
      const result = await Promise.race([
10✔
1503
        observedPromise,
1504
        new Promise<T>((_, reject) => {
1505
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
10✔
1506
          timer.unref?.()
10✔
1507
        })
1508
      ])
1509
      if (result && typeof result === 'object' && 'kind' in result) {
6!
1510
        if (result.kind === 'error') {
6✔
1511
          throw result.error
1✔
1512
        }
1513
        return result.value
5✔
1514
      }
1515
      return result
×
1516
    } finally {
1517
      if (timer) {
10!
1518
        clearTimeout(timer)
10✔
1519
      }
1520
    }
1521
  }
1522

1523
  private shouldBroadcastL1Invalidation(): boolean {
1524
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
142✔
1525
  }
1526

1527
  private scheduleGenerationCleanup(generation: number): void {
1528
    this.maintenance.scheduleGenerationCleanup(
2✔
1529
      generation,
1530
      async (generationToClean) => this.cleanupGeneration(generationToClean),
2✔
1531
      (failedGeneration, error) => {
1532
        this.logger.warn?.('generation-cleanup-error', {
1✔
1533
          generation: failedGeneration,
1534
          error: this.formatError(error)
1535
        })
1536
      }
1537
    )
1538
  }
1539

1540
  private async cleanupGeneration(generation: number): Promise<void> {
1541
    const prefix = `v${generation}:`
3✔
1542
    const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix)
3✔
1543
    for (const batch of planGenerationCleanupBatches(keys, this.options.generationCleanup)) {
2✔
1544
      await this.deleteKeys(batch)
1✔
1545
      await this.publishInvalidation({
1✔
1546
        scope: 'keys',
1547
        keys: batch,
1548
        sourceId: this.instanceId,
1549
        operation: 'invalidate'
1550
      })
1551
    }
1552
  }
1553

1554
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1555
    this.maintenance.initializeWriteBehindTimer(
203✔
1556
      this.options.writeStrategy,
1557
      options,
1558
      this.flushWriteBehindQueue.bind(this)
1559
    )
1560
  }
1561

1562
  private shouldWriteBehind(layer: CacheLayer): boolean {
1563
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
161✔
1564
  }
1565

1566
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1567
    await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this))
5✔
1568
  }
1569

1570
  private async flushWriteBehindQueue(): Promise<void> {
1571
    await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this))
26✔
1572
  }
1573

1574
  private async runWriteBehindBatch(batch: Array<() => Promise<void>>): Promise<void> {
1575
    const results = await Promise.allSettled(batch.map((operation) => operation()))
4✔
1576
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4✔
1577
    if (failures.length === 0) {
3✔
1578
      return
2✔
1579
    }
1580

1581
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1582
    this.logger.error?.('write-behind-flush-failure', {
1✔
1583
      failed: failures.length,
1584
      total: batch.length,
1585
      errors: failures.map((failure) => this.formatError(failure.reason))
1✔
1586
    })
1587
    this.emitError('write-behind', { failed: failures.length, total: batch.length })
3✔
1588
  }
1589

1590
  private buildLayerSetEntry(
1591
    layer: CacheLayer,
1592
    key: string,
1593
    kind: CacheWriteKind,
1594
    value: unknown,
1595
    options: CacheWriteOptions | undefined,
1596
    now: number
1597
  ): CacheLayerSetManyEntry {
1598
    const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl, value)
167✔
1599
    const staleWhileRevalidate = this.resolveLayerSeconds(
167✔
1600
      layer.name,
1601
      options?.staleWhileRevalidate,
1602
      this.options.staleWhileRevalidate
1603
    )
1604
    const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError)
167✔
1605
    const payload = createStoredValueEnvelope({
167✔
1606
      kind,
1607
      value,
1608
      freshTtlSeconds: freshTtl,
1609
      staleWhileRevalidateSeconds: staleWhileRevalidate,
1610
      staleIfErrorSeconds: staleIfError,
1611
      now
1612
    })
1613
    const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl
167✔
1614
    return {
167✔
1615
      key,
1616
      value: payload,
1617
      ttl
1618
    }
1619
  }
1620

1621
  private intersectKeys(groups: string[][]): string[] {
1622
    if (groups.length === 0) {
4✔
1623
      return []
1✔
1624
    }
1625

1626
    const [firstGroup, ...rest] = groups
3✔
1627
    const restSets = rest.map((group) => new Set(group))
4✔
1628
    return [...new Set(firstGroup)].filter((key) => restSets.every((group) => group.has(key)))
6✔
1629
  }
1630

1631
  private qualifyKey(key: string): string {
1632
    return qualifyGenerationKey(key, this.currentGeneration)
402✔
1633
  }
1634

1635
  private qualifyPattern(pattern: string): string {
1636
    return qualifyGenerationPattern(pattern, this.currentGeneration)
5✔
1637
  }
1638

1639
  private stripQualifiedKey(key: string): string {
1640
    return stripGenerationPrefix(key, this.currentGeneration)
11✔
1641
  }
1642

1643
  private async deleteKeysFromLayers(layers: CacheLayer[], keys: string[]): Promise<void> {
1644
    await Promise.all(
29✔
1645
      layers.map(async (layer) => {
1646
        if (this.shouldSkipLayer(layer)) {
34✔
1647
          return
1✔
1648
        }
1649

1650
        if (layer.deleteMany) {
33✔
1651
          try {
31✔
1652
            await layer.deleteMany(keys)
31✔
1653
          } catch (error) {
1654
            await this.handleLayerFailure(layer, 'delete', error)
×
1655
          }
1656
          return
31✔
1657
        }
1658

1659
        await Promise.all(
2✔
1660
          keys.map(async (key) => {
1661
            try {
2✔
1662
              await layer.delete(key)
2✔
1663
            } catch (error) {
1664
              await this.handleLayerFailure(layer, 'delete', error)
×
1665
            }
1666
          })
1667
        )
1668
      })
1669
    )
1670
  }
1671

1672
  private validateConfiguration(): void {
1673
    if (
207✔
1674
      this.options.broadcastL1Invalidation !== undefined &&
211✔
1675
      this.options.publishSetInvalidation !== undefined &&
1676
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1677
    ) {
1678
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1679
    }
1680

1681
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
206✔
1682
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1683
    }
1684

1685
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
204✔
1686
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
204✔
1687
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
204✔
1688
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
204✔
1689
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
204✔
1690
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
204✔
1691
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
204✔
1692
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
204✔
1693
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
204✔
1694
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
204✔
1695
    if (this.options.snapshotMaxBytes !== false) {
204✔
1696
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
202✔
1697
    }
1698
    if (this.options.snapshotMaxEntries !== false) {
203✔
1699
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
202✔
1700
    }
1701
    if (this.options.invalidationMaxKeys !== false) {
203✔
1702
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
201✔
1703
    }
1704
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
203✔
1705
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
203✔
1706
    validateCircuitBreakerOptions(this.options.circuitBreaker)
203✔
1707
    if (typeof this.options.generationCleanup === 'object') {
203✔
1708
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
2✔
1709
    }
1710
    if (this.options.generation !== undefined) {
203✔
1711
      validateNonNegativeNumber('generation', this.options.generation)
5✔
1712
    }
1713
  }
1714

1715
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1716
    if (!options) {
368✔
1717
      return
247✔
1718
    }
1719

1720
    validateLayerNumberOption('options.ttl', options.ttl)
121✔
1721
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
121✔
1722
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
121✔
1723
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
121✔
1724
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
121✔
1725
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
121✔
1726
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
121✔
1727
    validateAdaptiveTtlOptions(options.adaptiveTtl)
121✔
1728
    validateCircuitBreakerOptions(options.circuitBreaker)
121✔
1729
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
121✔
1730
    validateTags(options.tags)
121✔
1731
  }
1732

1733
  private assertActive(operation: string): void {
1734
    if (this.isDisconnecting) {
837✔
1735
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1736
    }
1737
  }
1738

1739
  private async awaitStartup(operation: string): Promise<void> {
1740
    this.assertActive(operation)
405✔
1741
    await this.startup
405✔
1742
    this.assertActive(operation)
400✔
1743
  }
1744

1745
  private async applyFreshReadPolicies<T>(
1746
    key: string,
1747
    hit: Extract<ReadHit<T>, { found: true }>,
1748
    options: CacheGetOptions | undefined,
1749
    fetcher?: () => Promise<T>
1750
  ): Promise<void> {
1751
    const plan = planFreshReadPolicies({
67✔
1752
      stored: hit.stored,
1753
      hasFetcher: Boolean(fetcher),
1754
      slidingTtl: options?.slidingTtl ?? false,
130✔
1755
      refreshAheadSeconds:
1756
        this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0
67!
1757
    })
1758

1759
    if (plan.refreshedStored) {
67✔
1760
      for (let index = 0; index <= hit.layerIndex; index += 1) {
4✔
1761
        const layer = this.layers[index]
6✔
1762
        if (!layer || this.shouldSkipLayer(layer)) {
6✔
1763
          continue
1✔
1764
        }
1765

1766
        try {
5✔
1767
          await layer.set(key, plan.refreshedStored, plan.refreshedStoredTtl)
5✔
1768
        } catch (error) {
1769
          await this.handleLayerFailure(layer, 'sliding-ttl', error)
×
1770
        }
1771
      }
1772
    }
1773

1774
    if (fetcher && plan.shouldScheduleBackgroundRefresh) {
67✔
1775
      this.scheduleBackgroundRefresh(key, fetcher, options)
1✔
1776
    }
1777
  }
1778

1779
  private shouldSkipLayer(layer: CacheLayer): boolean {
1780
    return shouldSkipDegradedLayer(this.layerDegradedUntil.get(layer.name))
641✔
1781
  }
1782

1783
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1784
    const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation)
10✔
1785
    if (!recovery.degrade) {
10✔
1786
      throw error
2✔
1787
    }
1788

1789
    this.layerDegradedUntil.set(layer.name, recovery.degradedUntil)
8✔
1790
    this.metricsCollector.increment('degradedOperations')
8✔
1791
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
8✔
1792
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
10✔
1793
    return null
10✔
1794
  }
1795

1796
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1797
    if (this.isGracefulDegradationEnabled()) {
6✔
1798
      await this.handleLayerFailure(layer, operation, error)
4✔
1799
      return
4✔
1800
    }
1801

1802
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
2✔
1803
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
6✔
1804
  }
1805

1806
  private isGracefulDegradationEnabled(): boolean {
1807
    return Boolean(this.options.gracefulDegradation)
6✔
1808
  }
1809

1810
  private recordCircuitFailure(key: string, options: CacheCircuitBreakerOptions | undefined, error: unknown): void {
1811
    if (!options) {
14✔
1812
      return
10✔
1813
    }
1814

1815
    this.circuitBreakerManager.recordFailure(key, options)
4✔
1816
    if (this.circuitBreakerManager.isOpen(key)) {
4!
1817
      this.metricsCollector.increment('circuitBreakerTrips')
4✔
1818
    }
1819
    this.emitError('fetch', { key, error: this.formatError(error) })
4✔
1820
  }
1821

1822
  private isNegativeStoredValue(stored: unknown): boolean {
1823
    return isStoredValueEnvelope(stored) && stored.kind === 'empty'
79✔
1824
  }
1825

1826
  private emitError(operation: string, context: Record<string, unknown>): void {
1827
    this.logger.error?.(operation, context)
18✔
1828
    if (this.listenerCount('error') > 0) {
18✔
1829
      this.emit('error', { operation, ...context })
5✔
1830
    }
1831
  }
1832

1833
  private isCacheSnapshotEntries(value: unknown): value is CacheSnapshotEntry[] {
1834
    return (
8✔
1835
      Array.isArray(value) &&
15✔
1836
      value.every((entry) => {
1837
        if (!entry || typeof entry !== 'object') {
7✔
1838
          return false
1✔
1839
        }
1840

1841
        const candidate = entry as Partial<CacheSnapshotEntry>
6✔
1842
        return (
6✔
1843
          typeof candidate.key === 'string' &&
21✔
1844
          (candidate.ttl === undefined ||
1845
            (typeof candidate.ttl === 'number' && Number.isFinite(candidate.ttl) && candidate.ttl >= 0))
1846
        )
1847
      })
1848
    )
1849
  }
1850

1851
  private sanitizeSnapshotValue(value: unknown): unknown {
1852
    return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value))
4✔
1853
  }
1854

1855
  private snapshotMaxBytes(): number | false {
1856
    return this.options.snapshotMaxBytes === false
9✔
1857
      ? false
1858
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
14✔
1859
  }
1860

1861
  private snapshotMaxEntries(): number | false {
1862
    return this.options.snapshotMaxEntries === false
6✔
1863
      ? false
1864
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
9✔
1865
  }
1866

1867
  private invalidationMaxKeys(): number | false {
1868
    return this.options.invalidationMaxKeys === false
38✔
1869
      ? false
1870
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
64✔
1871
  }
1872

1873
  private async collectKeysForTag(tag: string): Promise<string[]> {
1874
    const keys = new Set<string>()
15✔
1875

1876
    if (this.tagIndex.forEachKeyForTag) {
15✔
1877
      await this.tagIndex.forEachKeyForTag(tag, async (key) => {
14✔
1878
        keys.add(key)
18✔
1879
        this.assertWithinInvalidationKeyLimit(keys.size)
18✔
1880
      })
1881
      return [...keys]
12✔
1882
    }
1883

1884
    for (const key of await this.tagIndex.keysForTag(tag)) {
1✔
1885
      keys.add(key)
1✔
1886
      this.assertWithinInvalidationKeyLimit(keys.size)
1✔
1887
    }
1888

1889
    return [...keys]
1✔
1890
  }
1891

1892
  private assertWithinInvalidationKeyLimit(size: number): void {
1893
    const maxKeys = this.invalidationMaxKeys()
23✔
1894
    if (maxKeys !== false && size > maxKeys) {
23✔
1895
      throw new Error(`Invalidation matched too many keys (${size} > ${maxKeys}).`)
3✔
1896
    }
1897
  }
1898

1899
  private async visitExportEntries(
1900
    maxEntries: number | false,
1901
    visitor: (entry: CacheSnapshotEntry) => Promise<void> | void
1902
  ): Promise<void> {
1903
    const exported = new Set<string>()
6✔
1904

1905
    for (const layer of this.layers) {
6✔
1906
      if (!layer.keys && !layer.forEachKey) {
8✔
1907
        continue
1✔
1908
      }
1909

1910
      const visitKey = async (key: string): Promise<void> => {
7✔
1911
        const exportedKey = this.stripQualifiedKey(key)
11✔
1912
        if (exported.has(exportedKey)) {
11✔
1913
          return
3✔
1914
        }
1915

1916
        const stored = await this.readLayerEntry(layer, key)
8✔
1917
        if (stored === null) {
8✔
1918
          return
1✔
1919
        }
1920

1921
        exported.add(exportedKey)
7✔
1922
        if (maxEntries !== false && exported.size > maxEntries) {
7✔
1923
          throw new Error(`Snapshot export exceeds snapshotMaxEntries limit (${exported.size} > ${maxEntries}).`)
1✔
1924
        }
1925
        await visitor({
6✔
1926
          key: exportedKey,
1927
          value: stored,
1928
          ttl: remainingStoredTtlSeconds(stored)
1929
        })
1930
      }
1931

1932
      if (layer.forEachKey) {
7✔
1933
        await layer.forEachKey(visitKey)
5✔
1934
        continue
4✔
1935
      }
1936

1937
      const keys = await layer.keys?.()
2✔
1938
      for (const key of keys ?? []) {
2!
1939
        await visitKey(key)
2✔
1940
      }
1941
    }
1942
  }
1943
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc