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

flyingsquirrel0419 / layercache / 24088534534

07 Apr 2026 03:03PM UTC coverage: 93.06% (+13.3%) from 79.737%
24088534534

push

github

flyingsquirrel0419
Fix CI timezone-sensitive TTL test

1425 of 1582 branches covered (90.08%)

Branch coverage included in aggregate %.

2544 of 2683 relevant lines covered (94.82%)

249.62 hits per line

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

91.18
/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
  validateAdaptiveTtlOptions,
13
  validateCacheKey,
14
  validateCircuitBreakerOptions,
15
  validateLayerNumberOption,
16
  validateNonNegativeNumber,
17
  validatePattern,
18
  validatePositiveNumber,
19
  validateRateLimitOptions,
20
  validateTag,
21
  validateTags,
22
  validateTtlPolicy
23
} from './internal/CacheStackValidation'
24
import { CircuitBreakerManager } from './internal/CircuitBreakerManager'
25
import { FetchRateLimiter } from './internal/FetchRateLimiter'
26
import { MetricsCollector } from './internal/MetricsCollector'
27
import {
28
  createStoredValueEnvelope,
29
  isStoredValueEnvelope,
30
  refreshStoredEnvelope,
31
  remainingFreshTtlSeconds,
32
  remainingStoredTtlSeconds,
33
  resolveStoredValue
34
} from './internal/StoredValue'
35
import { TtlResolver } from './internal/TtlResolver'
36
import { TagIndex } from './invalidation/TagIndex'
37
import { JsonSerializer } from './serialization/JsonSerializer'
38
import { StampedeGuard } from './stampede/StampedeGuard'
39
import {
40
  type CacheAdaptiveTtlOptions,
41
  type CacheCircuitBreakerOptions,
42
  type CacheGetOptions,
43
  type CacheHealthCheckResult,
44
  type CacheHitRateSnapshot,
45
  type CacheInspectResult,
46
  type CacheLayer,
47
  type CacheLayerSetManyEntry,
48
  type CacheLogger,
49
  type CacheMGetEntry,
50
  type CacheMSetEntry,
51
  type CacheMetricsSnapshot,
52
  CacheMissError,
53
  type CacheSingleFlightExecutionOptions,
54
  type CacheSnapshotEntry,
55
  type CacheStackEvents,
56
  type CacheStackOptions,
57
  type CacheStatsSnapshot,
58
  type CacheTagIndex,
59
  type CacheTtlPolicy,
60
  type CacheWarmEntry,
61
  type CacheWarmOptions,
62
  type CacheWarmProgress,
63
  type CacheWrapOptions,
64
  type CacheWriteBehindOptions,
65
  type CacheWriteOptions,
66
  type InvalidationMessage,
67
  type LayerTtlMap
68
} from './types'
69

70
const DEFAULT_SINGLE_FLIGHT_LEASE_MS = 30_000
10✔
71
const DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5_000
10✔
72
const DEFAULT_SINGLE_FLIGHT_POLL_MS = 50
10✔
73
const DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 30_000
10✔
74
const DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1_024 * 1_024
10✔
75
const DEFAULT_SNAPSHOT_MAX_ENTRIES = 10_000
10✔
76
const DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50
10✔
77
const DEFAULT_INVALIDATION_MAX_KEYS = 10_000
10✔
78
const DEFAULT_MAX_PROFILE_ENTRIES = 100_000
10✔
79

80
type ReadMode = 'allow-stale' | 'fresh-only'
81
type CacheWriteKind = 'value' | 'empty'
82

83
type ReadHit<T> =
84
  | {
85
      found: true
86
      value: T | null
87
      stored: unknown
88
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
89
      layerIndex: number
90
      layerName: string
91
    }
92
  | { found: false; value: null; stored: null; state: 'miss' }
93

94
class DebugLogger implements CacheLogger {
95
  private readonly enabled: boolean
96

97
  constructor(enabled: boolean) {
98
    this.enabled = enabled
183✔
99
  }
100

101
  debug(message: string, context?: Record<string, unknown>): void {
102
    this.write('debug', message, context)
528✔
103
  }
104

105
  info(message: string, context?: Record<string, unknown>): void {
106
    this.write('info', message, context)
3✔
107
  }
108

109
  warn(message: string, context?: Record<string, unknown>): void {
110
    this.write('warn', message, context)
37✔
111
  }
112

113
  error(message: string, context?: Record<string, unknown>): void {
114
    this.write('error', message, context)
15✔
115
  }
116

117
  private write(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
118
    if (!this.enabled) {
583✔
119
      return
581✔
120
    }
121

122
    const suffix = context ? ` ${JSON.stringify(context)}` : ''
2✔
123
    console[level](`[layercache] ${message}${suffix}`)
583✔
124
  }
125
}
126

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

138
export class CacheStack extends EventEmitter {
139
  private readonly stampedeGuard = new StampedeGuard()
193✔
140
  private readonly metricsCollector = new MetricsCollector()
193✔
141
  private readonly instanceId = createInstanceId()
193✔
142
  private readonly startup: Promise<void>
143
  private unsubscribeInvalidation?: () => Promise<void> | void
144
  private readonly logger: CacheLogger
145
  private readonly tagIndex: CacheTagIndex
146
  private readonly keyDiscovery: CacheKeyDiscovery
147
  private readonly fetchRateLimiter = new FetchRateLimiter()
193✔
148
  private readonly snapshotSerializer = new JsonSerializer()
193✔
149
  private readonly backgroundRefreshes = new Map<string, Promise<void>>()
193✔
150
  private readonly layerDegradedUntil = new Map<string, number>()
193✔
151
  private readonly keyEpochs = new Map<string, number>()
193✔
152
  private readonly ttlResolver: TtlResolver
153
  private readonly circuitBreakerManager: CircuitBreakerManager
154
  private currentGeneration?: number
155
  private readonly writeBehindQueue: Array<() => Promise<void>> = []
193✔
156
  private writeBehindTimer?: ReturnType<typeof setInterval>
157
  private writeBehindFlushPromise?: Promise<void>
158
  private generationCleanupPromise?: Promise<void>
159
  private clearEpoch = 0
193✔
160
  private isDisconnecting = false
193✔
161
  private disconnectPromise?: Promise<void>
162

163
  constructor(
164
    private readonly layers: CacheLayer[],
193✔
165
    private readonly options: CacheStackOptions = {}
193✔
166
  ) {
167
    super()
193✔
168

169
    if (layers.length === 0) {
193✔
170
      throw new Error('CacheStack requires at least one cache layer.')
1✔
171
    }
172

173
    this.validateConfiguration()
192✔
174

175
    const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES
192✔
176
    this.ttlResolver = new TtlResolver({ maxProfileEntries })
193✔
177
    this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries })
193✔
178
    this.currentGeneration = options.generation
193✔
179

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

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

221
  /**
222
   * Read-through cache get.
223
   * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
224
   * and stores the result across all layers. Returns `null` if the key is not found
225
   * and no `fetcher` is provided.
226
   */
227
  async get<T>(key: string, fetcher?: () => Promise<T>, options?: CacheGetOptions): Promise<T | null> {
228
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
215✔
229
    this.validateWriteOptions(options)
215✔
230
    await this.awaitStartup('get')
215✔
231
    return this.getPrepared(normalizedKey, fetcher, options)
209✔
232
  }
233

234
  private async getPrepared<T>(
235
    normalizedKey: string,
236
    fetcher?: () => Promise<T>,
237
    options?: CacheGetOptions
238
  ): Promise<T | null> {
239
    const hit = await this.readFromLayers<T>(normalizedKey, options, 'allow-stale')
211✔
240
    if (hit.found) {
211✔
241
      this.ttlResolver.recordAccess(normalizedKey)
70✔
242
      if (this.isNegativeStoredValue(hit.stored)) {
70✔
243
        this.metricsCollector.increment('negativeCacheHits')
1✔
244
      }
245

246
      if (hit.state === 'fresh') {
70✔
247
        this.metricsCollector.increment('hits')
62✔
248
        await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher)
62✔
249
        return hit.value
62✔
250
      }
251

252
      if (hit.state === 'stale-while-revalidate') {
8✔
253
        this.metricsCollector.increment('hits')
7✔
254
        this.metricsCollector.increment('staleHits')
7✔
255
        this.emit('stale-serve', { key: normalizedKey, state: hit.state, layer: hit.layerName })
7✔
256
        if (fetcher) {
7!
257
          this.scheduleBackgroundRefresh(normalizedKey, fetcher, options)
7✔
258
        }
259
        return hit.value
7✔
260
      }
261

262
      if (!fetcher) {
1!
263
        this.metricsCollector.increment('hits')
×
264
        this.metricsCollector.increment('staleHits')
×
265
        this.emit('stale-serve', { key: normalizedKey, state: hit.state, layer: hit.layerName })
×
266
        return hit.value
×
267
      }
268

269
      try {
1✔
270
        return await this.fetchWithGuards(normalizedKey, fetcher, options)
1✔
271
      } catch (error) {
272
        this.metricsCollector.increment('staleHits')
1✔
273
        this.metricsCollector.increment('refreshErrors')
1✔
274
        this.logger.debug?.('stale-if-error', { key: normalizedKey, error: this.formatError(error) })
1✔
275
        return hit.value
1✔
276
      }
277
    }
278

279
    this.metricsCollector.increment('misses')
141✔
280
    if (!fetcher) {
141✔
281
      return null
47✔
282
    }
283

284
    return this.fetchWithGuards(normalizedKey, fetcher, options)
94✔
285
  }
286

287
  /**
288
   * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
289
   * Fetches and caches the value if not already present.
290
   */
291
  async getOrSet<T>(key: string, fetcher: () => Promise<T>, options?: CacheGetOptions): Promise<T | null> {
292
    return this.get(key, fetcher, options)
2✔
293
  }
294

295
  /**
296
   * Like `get()`, but throws `CacheMissError` instead of returning `null`.
297
   * Useful when the value is expected to exist or the fetcher is expected to
298
   * return non-null.
299
   */
300
  async getOrThrow<T>(key: string, fetcher?: () => Promise<T>, options?: CacheGetOptions): Promise<T> {
301
    const value = await this.get(key, fetcher, options)
2✔
302
    if (value === null) {
2✔
303
      throw new CacheMissError(key)
1✔
304
    }
305
    return value
1✔
306
  }
307

308
  /**
309
   * Returns true if the given key exists and is not expired in any layer.
310
   */
311
  async has(key: string): Promise<boolean> {
312
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
5✔
313
    await this.awaitStartup('has')
5✔
314

315
    for (const layer of this.layers) {
5✔
316
      if (this.shouldSkipLayer(layer)) {
7!
317
        continue
×
318
      }
319
      if (layer.has) {
7✔
320
        try {
2✔
321
          const exists = await layer.has(normalizedKey)
2✔
322
          if (exists) {
2✔
323
            return true
1✔
324
          }
325
        } catch {
326
          await this.reportRecoverableLayerFailure(layer, 'has', new Error(`has() failed for layer "${layer.name}"`))
×
327
          // fall through to next layer
328
        }
329
      } else {
330
        try {
5✔
331
          const value = await layer.get(normalizedKey)
5✔
332
          if (value !== null) {
4✔
333
            return true
2✔
334
          }
335
        } catch (error) {
336
          await this.reportRecoverableLayerFailure(layer, 'has', error)
1✔
337
          // fall through
338
        }
339
      }
340
    }
341
    return false
2✔
342
  }
343

344
  /**
345
   * Returns the remaining TTL in seconds for the key in the fastest layer
346
   * that has it, or null if the key is not found / has no TTL.
347
   */
348
  async ttl(key: string): Promise<number | null> {
349
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
3✔
350
    await this.awaitStartup('ttl')
3✔
351

352
    for (const layer of this.layers) {
3✔
353
      if (this.shouldSkipLayer(layer)) {
5✔
354
        continue
1✔
355
      }
356
      if (layer.ttl) {
4!
357
        try {
4✔
358
          const remaining = await layer.ttl(normalizedKey)
4✔
359
          if (remaining !== null) {
3!
360
            return remaining
3✔
361
          }
362
        } catch {
363
          // fall through
364
        }
365
      }
366
    }
367
    return null
×
368
  }
369

370
  /**
371
   * Stores a value in all cache layers. Overwrites any existing value.
372
   */
373
  async set<T>(key: string, value: T, options?: CacheWriteOptions): Promise<void> {
374
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
92✔
375
    this.validateWriteOptions(options)
92✔
376
    await this.awaitStartup('set')
92✔
377
    await this.storeEntry(normalizedKey, 'value', value, options)
88✔
378
  }
379

380
  /**
381
   * Deletes the key from all layers and publishes an invalidation message.
382
   */
383
  async delete(key: string): Promise<void> {
384
    const normalizedKey = this.qualifyKey(validateCacheKey(key))
7✔
385
    await this.awaitStartup('delete')
7✔
386
    await this.deleteKeys([normalizedKey])
6✔
387
    await this.publishInvalidation({
6✔
388
      scope: 'key',
389
      keys: [normalizedKey],
390
      sourceId: this.instanceId,
391
      operation: 'delete'
392
    })
393
  }
394

395
  async clear(): Promise<void> {
396
    await this.awaitStartup('clear')
4✔
397
    this.beginClearEpoch()
4✔
398
    await Promise.all(this.layers.map((layer) => layer.clear()))
5✔
399
    await this.tagIndex.clear()
4✔
400
    this.ttlResolver.clearProfiles()
4✔
401
    this.circuitBreakerManager.clear()
4✔
402
    this.metricsCollector.increment('invalidations')
4✔
403
    this.logger.debug?.('clear')
4✔
404
    await this.publishInvalidation({ scope: 'clear', sourceId: this.instanceId, operation: 'clear' })
4✔
405
  }
406

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

426
  async mget<T>(entries: CacheMGetEntry<T>[]): Promise<Array<T | null>> {
427
    this.assertActive('mget')
9✔
428
    if (entries.length === 0) {
9✔
429
      return []
1✔
430
    }
431

432
    const normalizedEntries = entries.map((entry) => ({
18✔
433
      ...entry,
434
      key: this.qualifyKey(validateCacheKey(entry.key))
435
    }))
436
    normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
18✔
437
    const canFastPath = normalizedEntries.every((entry) => entry.fetch === undefined && entry.options === undefined)
16✔
438
    if (!canFastPath) {
8✔
439
      await this.awaitStartup('mget')
2✔
440
      const pendingReads = new Map<
2✔
441
        string,
442
        {
443
          promise: Promise<T | null>
444
          fetch?: () => Promise<T>
445
          optionsSignature: string
446
        }
447
      >()
448

449
      return Promise.all(
2✔
450
        normalizedEntries.map((entry) => {
451
          const optionsSignature = serializeOptions(entry.options)
4✔
452
          const existing = pendingReads.get(entry.key)
4✔
453
          if (!existing) {
4✔
454
            const promise = this.getPrepared(entry.key, entry.fetch, entry.options)
2✔
455
            pendingReads.set(entry.key, {
2✔
456
              promise,
457
              fetch: entry.fetch,
458
              optionsSignature
459
            })
460
            return promise
2✔
461
          }
462

463
          if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2!
464
            throw new Error(`mget received conflicting entries for key "${entry.key}".`)
2✔
465
          }
466

467
          return existing.promise
×
468
        })
469
      )
470
    }
471

472
    await this.awaitStartup('mget')
6✔
473
    const pending = new Set<string>()
6✔
474
    const indexesByKey = new Map<string, number[]>()
6✔
475
    const resultsByKey = new Map<string, T | null>()
6✔
476

477
    for (let index = 0; index < normalizedEntries.length; index += 1) {
6✔
478
      const entry = normalizedEntries[index]
14✔
479
      if (!entry) continue
14!
480
      const key = entry.key
14✔
481
      const indexes = indexesByKey.get(key) ?? []
14✔
482
      indexes.push(index)
14✔
483
      indexesByKey.set(key, indexes)
14✔
484
      pending.add(key)
14✔
485
    }
486

487
    for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
6✔
488
      const layer = this.layers[layerIndex]
6✔
489
      if (!layer) continue
6!
490
      const keys = [...pending]
6✔
491
      if (keys.length === 0) {
6!
492
        break
×
493
      }
494

495
      const values = layer.getMany
6!
496
        ? await layer.getMany(keys)
497
        : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)))
×
498

499
      for (let offset = 0; offset < values.length; offset += 1) {
×
500
        const key = keys[offset]
13✔
501
        const stored = values[offset]
13✔
502
        if (!key || stored === null) {
13✔
503
          continue
2✔
504
        }
505

506
        const resolved = resolveStoredValue<T>(stored)
11✔
507
        if (resolved.state === 'expired') {
11✔
508
          await layer.delete(key)
1✔
509
          continue
1✔
510
        }
511

512
        await this.tagIndex.touch(key)
10✔
513
        await this.backfill(key, stored, layerIndex - 1)
10✔
514
        resultsByKey.set(key, resolved.value)
10✔
515
        pending.delete(key)
10✔
516
        this.metricsCollector.increment('hits', indexesByKey.get(key)?.length ?? 1)
10!
517
      }
518
    }
519

520
    if (pending.size > 0) {
6✔
521
      for (const key of pending) {
2✔
522
        await this.tagIndex.remove(key)
3✔
523
        this.metricsCollector.increment('misses', indexesByKey.get(key)?.length ?? 1)
3!
524
      }
525
    }
526

527
    return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null)
14✔
528
  }
529

530
  async mset<T>(entries: CacheMSetEntry<T>[]): Promise<void> {
531
    this.assertActive('mset')
6✔
532
    const normalizedEntries = entries.map((entry) => ({
13✔
533
      ...entry,
534
      key: this.qualifyKey(validateCacheKey(entry.key))
535
    }))
536
    normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options))
13✔
537
    await this.awaitStartup('mset')
6✔
538
    await this.writeBatch(normalizedEntries)
6✔
539
  }
540

541
  async warm(entries: CacheWarmEntry[], options: CacheWarmOptions = {}): Promise<void> {
4✔
542
    this.assertActive('warm')
4✔
543
    const concurrency = Math.max(1, options.concurrency ?? 4)
4✔
544
    const total = entries.length
4✔
545
    let completed = 0
4✔
546
    const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
4!
547
    const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
4!
548
      while (queue.length > 0) {
4✔
549
        const entry = queue.shift()
6✔
550
        if (!entry) {
6!
551
          return
×
552
        }
553

554
        let success = false
6✔
555
        try {
6✔
556
          await this.get(entry.key, entry.fetcher, entry.options)
6✔
557
          this.emit('warm', { key: entry.key })
4✔
558
          success = true
4✔
559
        } catch (error) {
560
          this.emitError('warm', { key: entry.key, error: this.formatError(error) })
2✔
561
          if (!options.continueOnError) {
2✔
562
            throw error
1✔
563
          }
564
        } finally {
565
          completed += 1
6✔
566
          const progress: CacheWarmProgress = { completed, total, key: entry.key, success }
6✔
567
          options.onProgress?.(progress)
6✔
568
        }
569
      }
570
    })
571

572
    await Promise.all(workers)
4✔
573
  }
574

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

593
  /**
594
   * Creates a `CacheNamespace` that automatically prefixes all keys with
595
   * `prefix:`. Useful for multi-tenant or module-level isolation.
596
   */
597
  namespace(prefix: string): CacheNamespace {
598
    validateNamespaceKey(prefix)
33✔
599
    return new CacheNamespace(this, prefix)
33✔
600
  }
601

602
  async invalidateByTag(tag: string): Promise<void> {
603
    validateTag(tag)
8✔
604
    await this.awaitStartup('invalidateByTag')
8✔
605
    const keys = await this.collectKeysForTag(tag)
7✔
606
    await this.deleteKeys(keys)
6✔
607
    await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
608
  }
609

610
  async invalidateByTags(tags: string[], mode: 'any' | 'all' = 'any'): Promise<void> {
4✔
611
    if (tags.length === 0) {
4!
612
      return
×
613
    }
614

615
    validateTags(tags)
4✔
616
    await this.awaitStartup('invalidateByTags')
4✔
617
    const keysByTag = await Promise.all(tags.map((tag) => this.collectKeysForTag(tag)))
7✔
618
    const keys = mode === 'all' ? this.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())]
3✔
619
    this.assertWithinInvalidationKeyLimit(keys.length)
4✔
620

621
    await this.deleteKeys(keys)
4✔
622
    await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
3✔
623
  }
624

625
  async invalidateByPattern(pattern: string): Promise<void> {
626
    validatePattern(pattern)
5✔
627
    await this.awaitStartup('invalidateByPattern')
5✔
628
    const keys = await this.keyDiscovery.collectKeysMatchingPattern(
5✔
629
      this.qualifyPattern(pattern),
630
      this.invalidationMaxKeys()
631
    )
632
    await this.deleteKeys(keys)
4✔
633
    await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
4✔
634
  }
635

636
  async invalidateByPrefix(prefix: string): Promise<void> {
637
    await this.awaitStartup('invalidateByPrefix')
7✔
638
    const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix))
7✔
639
    const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys())
7✔
640
    await this.deleteKeys(keys)
6✔
641
    await this.publishInvalidation({ scope: 'keys', keys, sourceId: this.instanceId, operation: 'invalidate' })
6✔
642
  }
643

644
  getMetrics(): CacheMetricsSnapshot {
645
    return this.metricsCollector.snapshot
150✔
646
  }
647

648
  getStats(): CacheStatsSnapshot {
649
    return {
9✔
650
      metrics: this.getMetrics(),
651
      layers: this.layers.map((layer) => ({
10✔
652
        name: layer.name,
653
        isLocal: Boolean(layer.isLocal),
654
        degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
19✔
655
      })),
656
      backgroundRefreshes: this.backgroundRefreshes.size
657
    }
658
  }
659

660
  resetMetrics(): void {
661
    this.metricsCollector.reset()
×
662
  }
663

664
  /**
665
   * Returns computed hit-rate statistics (overall and per-layer).
666
   */
667
  getHitRate(): CacheHitRateSnapshot {
668
    return this.metricsCollector.hitRate()
6✔
669
  }
670

671
  async healthCheck(): Promise<CacheHealthCheckResult[]> {
672
    await this.startup
2✔
673

674
    return Promise.all(
2✔
675
      this.layers.map(async (layer) => {
676
        const startedAt = performance.now()
4✔
677
        try {
4✔
678
          const healthy = layer.ping ? await layer.ping() : true
4✔
679
          return {
4✔
680
            layer: layer.name,
681
            healthy,
682
            latencyMs: performance.now() - startedAt
683
          }
684
        } catch (error) {
685
          return {
1✔
686
            layer: layer.name,
687
            healthy: false,
688
            latencyMs: performance.now() - startedAt,
689
            error: this.formatError(error)
690
          }
691
        }
692
      })
693
    )
694
  }
695

696
  /**
697
   * Rotates the active generation prefix used for all future cache keys.
698
   * Previous-generation keys remain in the underlying layers until they expire,
699
   * unless `generationCleanup` is enabled to prune them in the background.
700
   */
701
  bumpGeneration(nextGeneration?: number): number {
702
    const current = this.currentGeneration ?? 0
2!
703
    const previousGeneration = this.currentGeneration
2✔
704
    this.currentGeneration = nextGeneration ?? current + 1
2✔
705
    if (
2✔
706
      previousGeneration !== undefined &&
6✔
707
      previousGeneration !== this.currentGeneration &&
708
      this.shouldCleanupGenerations()
709
    ) {
710
      this.scheduleGenerationCleanup(previousGeneration)
1✔
711
    }
712
    return this.currentGeneration
2✔
713
  }
714

715
  /**
716
   * Returns detailed metadata about a single cache key: which layers contain it,
717
   * remaining fresh/stale/error TTLs, and associated tags.
718
   * Returns `null` if the key does not exist in any layer.
719
   */
720
  async inspect(key: string): Promise<CacheInspectResult | null> {
721
    const userKey = validateCacheKey(key)
3✔
722
    const normalizedKey = this.qualifyKey(userKey)
3✔
723
    await this.awaitStartup('inspect')
3✔
724

725
    const foundInLayers: string[] = []
3✔
726
    let freshTtlSeconds: number | null = null
3✔
727
    let staleTtlSeconds: number | null = null
3✔
728
    let errorTtlSeconds: number | null = null
3✔
729
    let isStale = false
3✔
730

731
    for (const layer of this.layers) {
3✔
732
      if (this.shouldSkipLayer(layer)) {
3!
733
        continue
×
734
      }
735
      const stored = await this.readLayerEntry(layer, normalizedKey)
3✔
736
      if (stored === null) {
3✔
737
        continue
1✔
738
      }
739

740
      const resolved = resolveStoredValue(stored)
2✔
741
      if (resolved.state === 'expired') {
2!
742
        continue
×
743
      }
744

745
      foundInLayers.push(layer.name)
2✔
746

747
      // Take TTL info from the first (fastest) layer that has it
748
      if (foundInLayers.length === 1 && resolved.envelope) {
2!
749
        const now = Date.now()
2✔
750
        freshTtlSeconds =
2✔
751
          resolved.envelope.freshUntil !== null
2!
752
            ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1_000))
753
            : null
754
        staleTtlSeconds =
2✔
755
          resolved.envelope.staleUntil !== null
2✔
756
            ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1_000))
757
            : null
758
        errorTtlSeconds =
2✔
759
          resolved.envelope.errorUntil !== null
2✔
760
            ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1_000))
761
            : null
762
        isStale = resolved.state === 'stale-while-revalidate' || resolved.state === 'stale-if-error'
2✔
763
      }
764
    }
765

766
    if (foundInLayers.length === 0) {
3✔
767
      return null
1✔
768
    }
769

770
    const tags = await this.getTagsForKey(normalizedKey)
2✔
771

772
    return { key: userKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags }
2✔
773
  }
774

775
  async exportState(): Promise<CacheSnapshotEntry[]> {
776
    await this.awaitStartup('exportState')
2✔
777
    const entries: CacheSnapshotEntry[] = []
2✔
778
    await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
2✔
779
      entries.push(entry)
2✔
780
    })
781
    return entries
1✔
782
  }
783

784
  async importState(entries: CacheSnapshotEntry[]): Promise<void> {
785
    await this.awaitStartup('importState')
4✔
786
    const normalizedEntries = entries.map((entry) => ({
4✔
787
      key: this.qualifyKey(validateCacheKey(entry.key)),
788
      value: entry.value,
789
      ttl: entry.ttl
790
    }))
791

792
    for (let index = 0; index < normalizedEntries.length; index += DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE) {
4✔
793
      const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE)
3✔
794
      await Promise.all(
3✔
795
        batch.map(async (entry) => {
796
          await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)))
3✔
797
          await this.tagIndex.touch(entry.key)
3✔
798
        })
799
      )
800
    }
801
  }
802

803
  async persistToFile(filePath: string): Promise<void> {
804
    this.assertActive('persistToFile')
4✔
805
    const { promises: fs } = await import('node:fs')
4✔
806
    const path = await import('node:path')
4✔
807
    const targetPath = await validateSnapshotFilePath(filePath, 'write', this.options.snapshotBaseDir)
4✔
808
    const tempPath = path.join(
2✔
809
      path.dirname(targetPath),
810
      `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
811
    )
812
    let handle: import('node:fs/promises').FileHandle | undefined
813

814
    try {
2✔
815
      handle = await fs.open(tempPath, 'wx')
2✔
816
      const openedHandle = handle
2✔
817
      await openedHandle.writeFile('[', 'utf8')
2✔
818

819
      let wroteAny = false
2✔
820
      await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
2✔
821
        await openedHandle.writeFile(wroteAny ? ',\n' : '\n', 'utf8')
1!
822
        await openedHandle.writeFile(JSON.stringify(entry, null, 2), 'utf8')
1✔
823
        wroteAny = true
1✔
824
      })
825

826
      await openedHandle.writeFile(wroteAny ? '\n]' : ']', 'utf8')
2✔
827
      await openedHandle.close()
2✔
828
      handle = undefined
2✔
829
      await fs.rename(tempPath, targetPath)
2✔
830
    } catch (error) {
831
      await handle?.close().catch(() => undefined)
×
832
      await fs.unlink(tempPath).catch(() => undefined)
×
833
      throw error
×
834
    }
835
  }
836

837
  async restoreFromFile(filePath: string): Promise<void> {
838
    this.assertActive('restoreFromFile')
9✔
839
    const { promises: fs, constants } = await import('node:fs')
9✔
840
    const validatedPath = await validateSnapshotFilePath(filePath, 'read', this.options.snapshotBaseDir)
9✔
841
    const handle = await fs.open(validatedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
7!
842
    const snapshotMaxBytes = this.snapshotMaxBytes()
7✔
843
    let raw: string
844
    try {
7✔
845
      if (snapshotMaxBytes !== false) {
7!
846
        const stat = await handle.stat()
7✔
847
        if (stat.size > snapshotMaxBytes) {
7✔
848
          throw new Error(
2✔
849
            `Snapshot file exceeds snapshotMaxBytes limit (${stat.size} bytes > ${snapshotMaxBytes} bytes).`
850
          )
851
        }
852
      }
853

854
      raw = await readUtf8HandleWithLimit(handle, snapshotMaxBytes)
5✔
855
    } finally {
856
      await handle.close()
7✔
857
    }
858

859
    let parsed: unknown
860
    try {
5✔
861
      parsed = JSON.parse(raw)
5✔
862
    } catch (cause) {
863
      throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`)
×
864
    }
865
    if (!this.isCacheSnapshotEntries(parsed)) {
5✔
866
      throw new Error('Invalid snapshot file: expected an array of { key: string, value, ttl? } entries')
1✔
867
    }
868
    await this.importState(
4✔
869
      parsed.map((entry) => ({
4✔
870
        key: entry.key,
871
        value: this.sanitizeSnapshotValue(entry.value),
872
        ttl: entry.ttl
873
      }))
874
    )
875
  }
876

877
  async disconnect(): Promise<void> {
878
    if (!this.disconnectPromise) {
26!
879
      this.isDisconnecting = true
26✔
880
      this.disconnectPromise = (async () => {
26✔
881
        await this.startup
26✔
882
        await this.unsubscribeInvalidation?.()
26✔
883
        await this.flushWriteBehindQueue()
26✔
884
        await this.generationCleanupPromise
26✔
885
        await Promise.allSettled([...this.backgroundRefreshes.values()])
26✔
886
        if (this.writeBehindTimer) {
26✔
887
          clearInterval(this.writeBehindTimer)
1✔
888
          this.writeBehindTimer = undefined
1✔
889
        }
890
        await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()))
38✔
891
      })()
892
    }
893

894
    await this.disconnectPromise
26✔
895
  }
896

897
  private async initialize(): Promise<void> {
898
    if (!this.options.invalidationBus) {
188✔
899
      return
173✔
900
    }
901

902
    this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
15✔
903
      await this.handleInvalidationMessage(message)
10✔
904
    })
905
  }
906

907
  private async fetchWithGuards<T>(
908
    key: string,
909
    fetcher: () => Promise<T>,
910
    options?: CacheGetOptions,
911
    expectedClearEpoch?: number,
912
    expectedKeyEpoch?: number
913
  ): Promise<T | null> {
914
    const fetchTask = async (): Promise<T | null> => {
102✔
915
      const secondHit = await this.readFromLayers<T>(key, options, 'fresh-only')
101✔
916
      if (secondHit.found) {
101✔
917
        this.metricsCollector.increment('hits')
49✔
918
        return secondHit.value
49✔
919
      }
920

921
      return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
52✔
922
    }
923

924
    const singleFlightTask = async (): Promise<T | null> => {
102✔
925
      if (!this.options.singleFlightCoordinator) {
102✔
926
        return fetchTask()
100✔
927
      }
928

929
      return this.options.singleFlightCoordinator.execute(key, this.resolveSingleFlightOptions(), fetchTask, () =>
2✔
930
        this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
1✔
931
      )
932
    }
933

934
    if (this.options.stampedePrevention === false) {
102!
935
      return singleFlightTask()
×
936
    }
937

938
    return this.stampedeGuard.execute(key, singleFlightTask)
102✔
939
  }
940

941
  private async waitForFreshValue<T>(
942
    key: string,
943
    fetcher: () => Promise<T>,
944
    options?: CacheGetOptions,
945
    expectedClearEpoch?: number,
946
    expectedKeyEpoch?: number
947
  ): Promise<T | null> {
948
    const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS
1✔
949
    const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
1✔
950
    const deadline = Date.now() + timeoutMs
1✔
951

952
    this.metricsCollector.increment('singleFlightWaits')
1✔
953
    this.emit('stampede-dedupe', { key })
1✔
954

955
    while (Date.now() < deadline) {
1✔
956
      const hit = await this.readFromLayers<T>(key, options, 'fresh-only')
2✔
957
      if (hit.found) {
2✔
958
        this.metricsCollector.increment('hits')
1✔
959
        return hit.value
1✔
960
      }
961
      await this.sleep(pollIntervalMs)
1✔
962
    }
963

964
    return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
×
965
  }
966

967
  private async fetchAndPopulate<T>(
968
    key: string,
969
    fetcher: () => Promise<T>,
970
    options?: CacheGetOptions,
971
    expectedClearEpoch?: number,
972
    expectedKeyEpoch?: number
973
  ): Promise<T | null> {
974
    this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker)
52✔
975
    this.metricsCollector.increment('fetches')
52✔
976
    const fetchStart = Date.now()
52✔
977
    let fetched: T
978

979
    try {
52✔
980
      fetched = await this.fetchRateLimiter.schedule(
52✔
981
        options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
95✔
982
        { key, fetcher },
983
        fetcher
984
      )
985
      this.circuitBreakerManager.recordSuccess(key)
39✔
986
      this.logger.debug?.('fetch', { key, durationMs: Date.now() - fetchStart })
39✔
987
    } catch (error) {
988
      this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error)
10✔
989
      throw error
10✔
990
    }
991

992
    if (fetched === null || fetched === undefined) {
39✔
993
      if (!this.shouldNegativeCache(options)) {
3✔
994
        return null
1✔
995
      }
996

997
      if (this.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
2✔
998
        this.logger.debug?.('skip-negative-store-after-invalidation', {
1✔
999
          key,
1000
          expectedClearEpoch,
1001
          clearEpoch: this.clearEpoch,
1002
          expectedKeyEpoch,
1003
          keyEpoch: this.currentKeyEpoch(key)
1004
        })
1005
        return null
1✔
1006
      }
1007

1008
      await this.storeEntry(key, 'empty', null, options)
1✔
1009
      return null
1✔
1010
    }
1011

1012
    // Conditional caching: skip storage if shouldCache returns false
1013
    if (options?.shouldCache) {
36✔
1014
      try {
1✔
1015
        if (!options.shouldCache(fetched)) {
1!
1016
          return fetched
×
1017
        }
1018
      } catch (error) {
1019
        this.logger.warn?.('shouldCache-error', { key, error: this.formatError(error) })
1✔
1020
      }
1021
    }
1022

1023
    if (this.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
36✔
1024
      this.logger.debug?.('skip-store-after-invalidation', {
2✔
1025
        key,
1026
        expectedClearEpoch,
1027
        clearEpoch: this.clearEpoch,
1028
        expectedKeyEpoch,
1029
        keyEpoch: this.currentKeyEpoch(key)
1030
      })
1031
      return fetched
2✔
1032
    }
1033

1034
    await this.storeEntry(key, 'value', fetched, options)
34✔
1035
    return fetched
34✔
1036
  }
1037

1038
  private async storeEntry(
1039
    key: string,
1040
    kind: CacheWriteKind,
1041
    value: unknown,
1042
    options?: CacheWriteOptions
1043
  ): Promise<void> {
1044
    const clearEpoch = this.clearEpoch
123✔
1045
    const keyEpoch = this.currentKeyEpoch(key)
123✔
1046
    await this.writeAcrossLayers(key, kind, value, options)
123✔
1047
    if (this.isWriteOutdated(key, clearEpoch, keyEpoch)) {
123!
1048
      return
×
1049
    }
1050
    if (options?.tags) {
123✔
1051
      await this.tagIndex.track(key, options.tags)
20✔
1052
    } else {
1053
      await this.tagIndex.touch(key)
103✔
1054
    }
1055

1056
    this.metricsCollector.increment('sets')
123✔
1057
    this.logger.debug?.('set', { key, kind, tags: options?.tags })
123✔
1058
    this.emit('set', { key, kind: kind as string, tags: options?.tags })
123✔
1059
    if (this.shouldBroadcastL1Invalidation()) {
123✔
1060
      await this.publishInvalidation({ scope: 'key', keys: [key], sourceId: this.instanceId, operation: 'write' })
2✔
1061
    }
1062
  }
1063

1064
  private async writeBatch(
1065
    entries: Array<{ key: string; value: unknown; options?: CacheWriteOptions }>
1066
  ): Promise<void> {
1067
    const now = Date.now()
6✔
1068
    const clearEpoch = this.clearEpoch
6✔
1069
    const entryEpochs = new Map(entries.map((entry) => [entry.key, this.currentKeyEpoch(entry.key)]))
13✔
1070
    const entriesByLayer = new Map<CacheLayer, CacheLayerSetManyEntry[]>()
6✔
1071
    const immediateOperations: Array<() => Promise<void>> = []
6✔
1072
    const deferredOperations: Array<() => Promise<void>> = []
6✔
1073

1074
    for (const entry of entries) {
6✔
1075
      for (const layer of this.layers) {
13✔
1076
        if (this.shouldSkipLayer(layer)) {
13!
1077
          continue
×
1078
        }
1079

1080
        const layerEntry = this.buildLayerSetEntry(layer, entry.key, 'value', entry.value, entry.options, now)
13✔
1081
        const bucket = entriesByLayer.get(layer) ?? []
13✔
1082
        bucket.push(layerEntry)
13✔
1083
        entriesByLayer.set(layer, bucket)
13✔
1084
      }
1085
    }
1086

1087
    for (const [layer, layerEntries] of entriesByLayer.entries()) {
6✔
1088
      const operation = async () => {
6✔
1089
        if (clearEpoch !== this.clearEpoch) {
6!
1090
          return
×
1091
        }
1092
        const activeEntries = layerEntries.filter(
6✔
1093
          (entry) => (entryEpochs.get(entry.key) ?? 0) === this.currentKeyEpoch(entry.key)
13!
1094
        )
1095
        if (activeEntries.length === 0) {
6!
1096
          return
×
1097
        }
1098
        try {
6✔
1099
          if (layer.setMany) {
6!
1100
            await layer.setMany(activeEntries)
6✔
1101
            return
6✔
1102
          }
1103

1104
          await Promise.all(activeEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)))
×
1105
        } catch (error) {
1106
          await this.handleLayerFailure(layer, 'write', error)
×
1107
        }
1108
      }
1109

1110
      if (this.shouldWriteBehind(layer)) {
6!
1111
        deferredOperations.push(operation)
×
1112
      } else {
1113
        immediateOperations.push(operation)
6✔
1114
      }
1115
    }
1116

1117
    await this.executeLayerOperations(immediateOperations, { key: 'batch', action: 'mset' })
6✔
1118
    await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)))
6✔
1119
    if (clearEpoch !== this.clearEpoch) {
6!
1120
      return
×
1121
    }
1122

1123
    for (const entry of entries) {
6✔
1124
      if (this.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
13!
1125
        continue
×
1126
      }
1127
      if (entry.options?.tags) {
13!
1128
        await this.tagIndex.track(entry.key, entry.options.tags)
×
1129
      } else {
1130
        await this.tagIndex.touch(entry.key)
13✔
1131
      }
1132

1133
      this.metricsCollector.increment('sets')
13✔
1134
      this.logger.debug?.('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
13✔
1135
      this.emit('set', { key: entry.key, kind: 'value', tags: entry.options?.tags })
13✔
1136
    }
1137

1138
    if (this.shouldBroadcastL1Invalidation()) {
6!
1139
      await this.publishInvalidation({
×
1140
        scope: 'keys',
1141
        keys: entries.map((entry) => entry.key),
×
1142
        sourceId: this.instanceId,
1143
        operation: 'write'
1144
      })
1145
    }
1146
  }
1147

1148
  private async readFromLayers<T>(
1149
    key: string,
1150
    options: CacheGetOptions | undefined,
1151
    mode: ReadMode
1152
  ): Promise<ReadHit<T>> {
1153
    let sawRetainableValue = false
314✔
1154

1155
    for (let index = 0; index < this.layers.length; index += 1) {
314✔
1156
      const layer = this.layers[index]
332✔
1157
      if (!layer) continue
332!
1158
      const readStart = performance.now()
332✔
1159
      const stored = await this.readLayerEntry(layer, key)
332✔
1160
      const readDuration = performance.now() - readStart
332✔
1161
      this.metricsCollector.recordLatency(layer.name, readDuration)
332✔
1162
      if (stored === null) {
332✔
1163
        this.metricsCollector.incrementLayer('missesByLayer', layer.name)
204✔
1164
        continue
204✔
1165
      }
1166

1167
      const resolved = resolveStoredValue<T>(stored)
128✔
1168
      if (resolved.state === 'expired') {
128!
1169
        await layer.delete(key)
×
1170
        continue
×
1171
      }
1172

1173
      sawRetainableValue = true
128✔
1174

1175
      if (mode === 'fresh-only' && resolved.state !== 'fresh') {
128✔
1176
        continue
8✔
1177
      }
1178

1179
      await this.tagIndex.touch(key)
120✔
1180
      await this.backfill(key, stored, index - 1, options)
120✔
1181
      this.metricsCollector.incrementLayer('hitsByLayer', layer.name)
120✔
1182
      this.logger.debug?.('hit', { key, layer: layer.name, state: resolved.state })
120✔
1183
      this.emit('hit', { key, layer: layer.name, state: resolved.state as CacheStackEvents['hit']['state'] })
332✔
1184
      return {
332✔
1185
        found: true,
1186
        value: resolved.value,
1187
        stored,
1188
        state: resolved.state,
1189
        layerIndex: index,
1190
        layerName: layer.name
1191
      }
1192
    }
1193

1194
    if (!sawRetainableValue) {
194✔
1195
      await this.tagIndex.remove(key)
186✔
1196
    }
1197

1198
    this.logger.debug?.('miss', { key, mode })
194✔
1199
    this.emit('miss', { key, mode })
314✔
1200
    return { found: false, value: null, stored: null, state: 'miss' }
314✔
1201
  }
1202

1203
  private async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
1204
    if (this.shouldSkipLayer(layer)) {
340✔
1205
      return null
1✔
1206
    }
1207

1208
    if (layer.getEntry) {
339✔
1209
      try {
336✔
1210
        return await layer.getEntry(key)
336✔
1211
      } catch (error) {
1212
        return this.handleLayerFailure(layer, 'read', error)
1✔
1213
      }
1214
    }
1215

1216
    try {
3✔
1217
      return await layer.get(key)
3✔
1218
    } catch (error) {
1219
      return this.handleLayerFailure(layer, 'read', error)
1✔
1220
    }
1221
  }
1222

1223
  private async backfill(key: string, stored: unknown, upToIndex: number, options?: CacheGetOptions): Promise<void> {
1224
    if (upToIndex < 0) {
130✔
1225
      return
120✔
1226
    }
1227

1228
    for (let index = 0; index <= upToIndex; index += 1) {
10✔
1229
      const layer = this.layers[index]
10✔
1230
      if (!layer || this.shouldSkipLayer(layer)) {
10✔
1231
        continue
1✔
1232
      }
1233

1234
      const ttl =
9✔
1235
        remainingStoredTtlSeconds(stored) ??
1236
        this.resolveLayerSeconds(layer.name, options?.ttl, undefined, layer.defaultTtl)
1237
      try {
10✔
1238
        await layer.set(key, stored, ttl)
10✔
1239
      } catch (error) {
1240
        await this.handleLayerFailure(layer, 'backfill', error)
×
1241
        continue
×
1242
      }
1243
      this.metricsCollector.increment('backfills')
9✔
1244
      this.logger.debug?.('backfill', { key, layer: layer.name })
9✔
1245
      this.emit('backfill', { key, layer: layer.name })
10✔
1246
    }
1247
  }
1248

1249
  private async writeAcrossLayers(
1250
    key: string,
1251
    kind: CacheWriteKind,
1252
    value: unknown,
1253
    options?: CacheWriteOptions
1254
  ): Promise<void> {
1255
    const now = Date.now()
123✔
1256
    const clearEpoch = this.clearEpoch
123✔
1257
    const keyEpoch = this.currentKeyEpoch(key)
123✔
1258
    const immediateOperations: Array<() => Promise<void>> = []
123✔
1259
    const deferredOperations: Array<() => Promise<void>> = []
123✔
1260

1261
    for (const layer of this.layers) {
123✔
1262
      const operation = async () => {
142✔
1263
        if (this.isWriteOutdated(key, clearEpoch, keyEpoch)) {
141!
1264
          return
×
1265
        }
1266
        if (this.shouldSkipLayer(layer)) {
141!
1267
          return
×
1268
        }
1269

1270
        const entry = this.buildLayerSetEntry(layer, key, kind, value, options, now)
141✔
1271
        try {
141✔
1272
          await layer.set(entry.key, entry.value, entry.ttl)
141✔
1273
        } catch (error) {
1274
          await this.handleLayerFailure(layer, 'write', error)
2✔
1275
        }
1276
      }
1277

1278
      if (this.shouldWriteBehind(layer)) {
142✔
1279
        deferredOperations.push(operation)
2✔
1280
      } else {
1281
        immediateOperations.push(operation)
140✔
1282
      }
1283
    }
1284

1285
    await this.executeLayerOperations(immediateOperations, { key, action: kind === 'empty' ? 'negative-set' : 'set' })
123✔
1286
    await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)))
123✔
1287
  }
1288

1289
  private async executeLayerOperations(
1290
    operations: Array<() => Promise<void>>,
1291
    context: { key: string; action: string }
1292
  ): Promise<void> {
1293
    if (this.options.writePolicy !== 'best-effort') {
129✔
1294
      await Promise.all(operations.map((operation) => operation()))
144✔
1295
      return
128✔
1296
    }
1297

1298
    const results = await Promise.allSettled(operations.map((operation) => operation()))
2✔
1299
    const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
2✔
1300
    if (failures.length === 0) {
1!
1301
      return
×
1302
    }
1303

1304
    this.metricsCollector.increment('writeFailures', failures.length)
1✔
1305
    this.logger.debug?.('write-failure', {
1✔
1306
      ...context,
1307
      failures: failures.map((failure) => this.formatError(failure.reason))
1✔
1308
    })
1309

1310
    if (failures.length === operations.length) {
129!
1311
      throw new AggregateError(
×
1312
        failures.map((failure) => failure.reason),
×
1313
        `${context.action} failed for every cache layer`
1314
      )
1315
    }
1316
  }
1317

1318
  private resolveFreshTtl(
1319
    key: string,
1320
    layerName: string,
1321
    kind: CacheWriteKind,
1322
    options: CacheWriteOptions | undefined,
1323
    fallbackTtl: number | undefined,
1324
    value: unknown
1325
  ): number | undefined {
1326
    return this.ttlResolver.resolveFreshTtl(
154✔
1327
      key,
1328
      layerName,
1329
      kind,
1330
      options,
1331
      fallbackTtl,
1332
      this.options.negativeTtl,
1333
      undefined,
1334
      value
1335
    )
1336
  }
1337

1338
  private resolveLayerSeconds(
1339
    layerName: string,
1340
    override: number | LayerTtlMap | undefined,
1341
    globalDefault?: number | LayerTtlMap,
1342
    fallback?: number
1343
  ): number | undefined {
1344
    return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback)
372✔
1345
  }
1346

1347
  private shouldNegativeCache(options?: CacheGetOptions): boolean {
1348
    return options?.negativeCache ?? this.options.negativeCaching ?? false
3✔
1349
  }
1350

1351
  private scheduleBackgroundRefresh<T>(key: string, fetcher: () => Promise<T>, options?: CacheGetOptions): void {
1352
    if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
9✔
1353
      return
2✔
1354
    }
1355

1356
    const clearEpoch = this.clearEpoch
7✔
1357
    const keyEpoch = this.currentKeyEpoch(key)
7✔
1358
    const refresh = (async () => {
7✔
1359
      this.metricsCollector.increment('refreshes')
7✔
1360
      try {
7✔
1361
        await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch)
7✔
1362
      } catch (error) {
1363
        this.metricsCollector.increment('refreshErrors')
3✔
1364
        this.logger.debug?.('refresh-error', { key, error: this.formatError(error) })
3✔
1365
      } finally {
1366
        this.backgroundRefreshes.delete(key)
7✔
1367
      }
1368
    })()
1369

1370
    this.backgroundRefreshes.set(key, refresh)
7✔
1371
  }
1372

1373
  private async runBackgroundRefresh<T>(
1374
    key: string,
1375
    fetcher: () => Promise<T>,
1376
    options?: CacheGetOptions,
1377
    expectedClearEpoch?: number,
1378
    expectedKeyEpoch?: number
1379
  ): Promise<void> {
1380
    const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS
7✔
1381
    await this.fetchWithGuards(
7✔
1382
      key,
1383
      () =>
1384
        this.withTimeout(fetcher(), timeoutMs, () => {
7✔
1385
          return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`)
3✔
1386
        }),
1387
      options,
1388
      expectedClearEpoch,
1389
      expectedKeyEpoch
1390
    )
1391
  }
1392

1393
  private resolveSingleFlightOptions(): CacheSingleFlightExecutionOptions {
1394
    return {
2✔
1395
      leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
4✔
1396
      waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
4✔
1397
      pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
4✔
1398
      renewIntervalMs: this.options.singleFlightRenewIntervalMs
1399
    }
1400
  }
1401

1402
  private async deleteKeys(keys: string[]): Promise<void> {
1403
    if (keys.length === 0) {
27✔
1404
      return
4✔
1405
    }
1406

1407
    this.bumpKeyEpochs(keys)
23✔
1408
    await this.deleteKeysFromLayers(this.layers, keys)
23✔
1409

1410
    for (const key of keys) {
23✔
1411
      await this.tagIndex.remove(key)
30✔
1412
      this.ttlResolver.deleteProfile(key)
30✔
1413
      this.circuitBreakerManager.delete(key)
30✔
1414
    }
1415

1416
    this.metricsCollector.increment('deletes', keys.length)
23✔
1417
    this.metricsCollector.increment('invalidations')
23✔
1418
    this.logger.debug?.('delete', { keys })
23✔
1419
    this.emit('delete', { keys })
27✔
1420
  }
1421

1422
  private async publishInvalidation(message: InvalidationMessage): Promise<void> {
1423
    if (!this.options.invalidationBus) {
33✔
1424
      return
28✔
1425
    }
1426

1427
    await this.options.invalidationBus.publish(message)
5✔
1428
  }
1429

1430
  private async handleInvalidationMessage(message: InvalidationMessage): Promise<void> {
1431
    if (message.sourceId === this.instanceId) {
13✔
1432
      return
6✔
1433
    }
1434

1435
    const localLayers = this.layers.filter((layer) => layer.isLocal)
10✔
1436
    if (message.scope === 'clear') {
7✔
1437
      this.beginClearEpoch()
2✔
1438
      await Promise.all(localLayers.map((layer) => layer.clear()))
2✔
1439
      await this.tagIndex.clear()
2✔
1440
      this.ttlResolver.clearProfiles()
2✔
1441
      this.circuitBreakerManager.clear()
2✔
1442
      return
2✔
1443
    }
1444

1445
    const keys = message.keys ?? []
5!
1446
    this.bumpKeyEpochs(keys)
13✔
1447
    await this.deleteKeysFromLayers(localLayers, keys)
13✔
1448

1449
    if (message.operation !== 'write') {
5✔
1450
      for (const key of keys) {
2✔
1451
        await this.tagIndex.remove(key)
3✔
1452
        this.ttlResolver.deleteProfile(key)
3✔
1453
        this.circuitBreakerManager.delete(key)
3✔
1454
      }
1455
    }
1456
  }
1457

1458
  private async getTagsForKey(key: string): Promise<string[]> {
1459
    if (this.tagIndex.tagsForKey) {
3!
1460
      return this.tagIndex.tagsForKey(key)
3✔
1461
    }
1462
    return []
×
1463
  }
1464

1465
  private formatError(error: unknown): string {
1466
    if (error instanceof Error) {
29!
1467
      return error.message
29✔
1468
    }
1469

1470
    return String(error)
×
1471
  }
1472

1473
  private sleep(ms: number): Promise<void> {
1474
    return new Promise((resolve) => setTimeout(resolve, ms))
1✔
1475
  }
1476

1477
  private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, onTimeout: () => Error): Promise<T> {
1478
    if (timeoutMs <= 0) {
9✔
1479
      return promise
1✔
1480
    }
1481

1482
    let timer: ReturnType<typeof setTimeout> | undefined
1483
    const observedPromise = promise.then(
8✔
1484
      (value) => ({ kind: 'value' as const, value }),
4✔
1485
      (error) => ({ kind: 'error' as const, error })
2✔
1486
    )
1487
    try {
8✔
1488
      const result = await Promise.race([
8✔
1489
        observedPromise,
1490
        new Promise<T>((_, reject) => {
1491
          timer = setTimeout(() => reject(onTimeout()), timeoutMs)
8✔
1492
          timer.unref?.()
8✔
1493
        })
1494
      ])
1495
      if (result && typeof result === 'object' && 'kind' in result) {
5!
1496
        if (result.kind === 'error') {
5✔
1497
          throw result.error
1✔
1498
        }
1499
        return result.value
4✔
1500
      }
1501
      return result
×
1502
    } finally {
1503
      if (timer) {
8!
1504
        clearTimeout(timer)
8✔
1505
      }
1506
    }
1507
  }
1508

1509
  private shouldBroadcastL1Invalidation(): boolean {
1510
    return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false
129✔
1511
  }
1512

1513
  private shouldCleanupGenerations(): boolean {
1514
    return Boolean(this.options.generationCleanup)
2✔
1515
  }
1516

1517
  private generationCleanupBatchSize(): number {
1518
    const configured =
1519
      typeof this.options.generationCleanup === 'object' ? this.options.generationCleanup.batchSize : undefined
1!
1520
    return configured ?? 500
1!
1521
  }
1522

1523
  private scheduleGenerationCleanup(generation: number): void {
1524
    const task = (this.generationCleanupPromise ?? Promise.resolve())
2✔
1525
      .then(() => this.cleanupGeneration(generation))
2✔
1526
      .catch((error) => {
1527
        this.logger.warn?.('generation-cleanup-error', {
1✔
1528
          generation,
1529
          error: this.formatError(error)
1530
        })
1531
      })
1532

1533
    this.generationCleanupPromise = task.finally(() => {
2✔
1534
      if (this.generationCleanupPromise === task) {
2!
1535
        this.generationCleanupPromise = undefined
×
1536
      }
1537
    })
1538
  }
1539

1540
  private async cleanupGeneration(generation: number): Promise<void> {
1541
    const prefix = `v${generation}:`
2✔
1542
    const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix)
2✔
1543
    if (keys.length === 0) {
2✔
1544
      return
1✔
1545
    }
1546

1547
    const batchSize = this.generationCleanupBatchSize()
1✔
1548
    for (let index = 0; index < keys.length; index += batchSize) {
1✔
1549
      const batch = keys.slice(index, index + batchSize)
1✔
1550
      await this.deleteKeys(batch)
1✔
1551
      await this.publishInvalidation({
1✔
1552
        scope: 'keys',
1553
        keys: batch,
1554
        sourceId: this.instanceId,
1555
        operation: 'invalidate'
1556
      })
1557
    }
1558
  }
1559

1560
  private initializeWriteBehind(options: CacheWriteBehindOptions | undefined): void {
1561
    if (this.options.writeStrategy !== 'write-behind') {
188✔
1562
      return
183✔
1563
    }
1564

1565
    const flushIntervalMs = options?.flushIntervalMs
5✔
1566
    if (!flushIntervalMs || flushIntervalMs <= 0) {
188✔
1567
      return
4✔
1568
    }
1569

1570
    this.writeBehindTimer = setInterval(() => {
1✔
1571
      void this.flushWriteBehindQueue()
×
1572
    }, flushIntervalMs)
1573
    this.writeBehindTimer.unref?.()
1✔
1574
  }
1575

1576
  private shouldWriteBehind(layer: CacheLayer): boolean {
1577
    return this.options.writeStrategy === 'write-behind' && !layer.isLocal
148✔
1578
  }
1579

1580
  private beginClearEpoch(): void {
1581
    this.clearEpoch += 1
6✔
1582
    this.keyEpochs.clear()
6✔
1583
    this.writeBehindQueue.length = 0
6✔
1584
  }
1585

1586
  private currentKeyEpoch(key: string): number {
1587
    return this.keyEpochs.get(key) ?? 0
598✔
1588
  }
1589

1590
  private bumpKeyEpochs(keys: string[]): void {
1591
    for (const key of keys) {
28✔
1592
      this.keyEpochs.set(key, this.currentKeyEpoch(key) + 1)
36✔
1593
    }
1594
  }
1595

1596
  private isWriteOutdated(key: string, expectedClearEpoch?: number, expectedKeyEpoch?: number): boolean {
1597
    if (expectedClearEpoch !== undefined && expectedClearEpoch !== this.clearEpoch) {
315✔
1598
      return true
1✔
1599
    }
1600

1601
    if (expectedKeyEpoch !== undefined && expectedKeyEpoch !== this.currentKeyEpoch(key)) {
314✔
1602
      return true
2✔
1603
    }
1604

1605
    return false
312✔
1606
  }
1607

1608
  private async enqueueWriteBehind(operation: () => Promise<void>): Promise<void> {
1609
    this.writeBehindQueue.push(operation)
5✔
1610
    const batchSize = this.options.writeBehind?.batchSize ?? 100
5!
1611
    const maxQueueSize = this.options.writeBehind?.maxQueueSize ?? batchSize * 10
5✔
1612

1613
    if (this.writeBehindQueue.length >= batchSize) {
5✔
1614
      await this.flushWriteBehindQueue()
3✔
1615
      return
3✔
1616
    }
1617

1618
    if (this.writeBehindQueue.length >= maxQueueSize) {
2!
1619
      await this.flushWriteBehindQueue()
×
1620
    }
1621
  }
1622

1623
  private async flushWriteBehindQueue(): Promise<void> {
1624
    if (this.writeBehindFlushPromise || this.writeBehindQueue.length === 0) {
29✔
1625
      await this.writeBehindFlushPromise
26✔
1626
      return
26✔
1627
    }
1628

1629
    const batchSize = this.options.writeBehind?.batchSize ?? 100
3!
1630
    const batch = this.writeBehindQueue.splice(0, batchSize)
29✔
1631
    this.writeBehindFlushPromise = (async () => {
29✔
1632
      const results = await Promise.allSettled(batch.map((operation) => operation()))
4✔
1633
      const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4✔
1634
      if (failures.length > 0) {
3✔
1635
        this.metricsCollector.increment('writeFailures', failures.length)
1✔
1636
        this.logger.error?.('write-behind-flush-failure', {
1✔
1637
          failed: failures.length,
1638
          total: batch.length,
1639
          errors: failures.map((failure) => this.formatError(failure.reason))
1✔
1640
        })
1641
        this.emitError('write-behind', { failed: failures.length, total: batch.length })
1✔
1642
      }
1643
    })()
1644

1645
    await this.writeBehindFlushPromise
29✔
1646
    this.writeBehindFlushPromise = undefined
3✔
1647

1648
    if (this.writeBehindQueue.length > 0) {
3!
1649
      await this.flushWriteBehindQueue()
×
1650
    }
1651
  }
1652

1653
  private buildLayerSetEntry(
1654
    layer: CacheLayer,
1655
    key: string,
1656
    kind: CacheWriteKind,
1657
    value: unknown,
1658
    options: CacheWriteOptions | undefined,
1659
    now: number
1660
  ): CacheLayerSetManyEntry {
1661
    const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl, value)
154✔
1662
    const staleWhileRevalidate = this.resolveLayerSeconds(
154✔
1663
      layer.name,
1664
      options?.staleWhileRevalidate,
1665
      this.options.staleWhileRevalidate
1666
    )
1667
    const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError)
154✔
1668
    const payload = createStoredValueEnvelope({
154✔
1669
      kind,
1670
      value,
1671
      freshTtlSeconds: freshTtl,
1672
      staleWhileRevalidateSeconds: staleWhileRevalidate,
1673
      staleIfErrorSeconds: staleIfError,
1674
      now
1675
    })
1676
    const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl
154✔
1677
    return {
154✔
1678
      key,
1679
      value: payload,
1680
      ttl
1681
    }
1682
  }
1683

1684
  private intersectKeys(groups: string[][]): string[] {
1685
    if (groups.length === 0) {
4✔
1686
      return []
1✔
1687
    }
1688

1689
    const [firstGroup, ...rest] = groups
3✔
1690
    if (!firstGroup) {
3!
1691
      return []
×
1692
    }
1693

1694
    const restSets = rest.map((group) => new Set(group))
4✔
1695
    return [...new Set(firstGroup)].filter((key) => restSets.every((group) => group.has(key)))
6✔
1696
  }
1697

1698
  private qualifyKey(key: string): string {
1699
    const prefix = this.generationPrefix()
364✔
1700
    return prefix ? `${prefix}${key}` : key
364✔
1701
  }
1702

1703
  private qualifyPattern(pattern: string): string {
1704
    const prefix = this.generationPrefix()
5✔
1705
    return prefix ? `${prefix}${pattern}` : pattern
5!
1706
  }
1707

1708
  private stripQualifiedKey(key: string): string {
1709
    const prefix = this.generationPrefix()
13✔
1710
    if (!prefix || !key.startsWith(prefix)) {
13✔
1711
      return key
8✔
1712
    }
1713
    return key.slice(prefix.length)
5✔
1714
  }
1715

1716
  private generationPrefix(): string {
1717
    if (this.currentGeneration === undefined) {
383✔
1718
      return ''
367✔
1719
    }
1720

1721
    return `v${this.currentGeneration}:`
16✔
1722
  }
1723

1724
  private async deleteKeysFromLayers(layers: CacheLayer[], keys: string[]): Promise<void> {
1725
    await Promise.all(
29✔
1726
      layers.map(async (layer) => {
1727
        if (this.shouldSkipLayer(layer)) {
34✔
1728
          return
1✔
1729
        }
1730

1731
        if (layer.deleteMany) {
33✔
1732
          try {
31✔
1733
            await layer.deleteMany(keys)
31✔
1734
          } catch (error) {
1735
            await this.handleLayerFailure(layer, 'delete', error)
×
1736
          }
1737
          return
31✔
1738
        }
1739

1740
        await Promise.all(
2✔
1741
          keys.map(async (key) => {
1742
            try {
2✔
1743
              await layer.delete(key)
2✔
1744
            } catch (error) {
1745
              await this.handleLayerFailure(layer, 'delete', error)
1✔
1746
            }
1747
          })
1748
        )
1749
      })
1750
    )
1751
  }
1752

1753
  private validateConfiguration(): void {
1754
    if (
192✔
1755
      this.options.broadcastL1Invalidation !== undefined &&
196✔
1756
      this.options.publishSetInvalidation !== undefined &&
1757
      this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation
1758
    ) {
1759
      throw new Error('broadcastL1Invalidation and publishSetInvalidation cannot conflict.')
1✔
1760
    }
1761

1762
    if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
191✔
1763
      throw new Error('singleFlightCoordinator requires stampedePrevention to remain enabled.')
2✔
1764
    }
1765

1766
    validateLayerNumberOption('negativeTtl', this.options.negativeTtl)
189✔
1767
    validateLayerNumberOption('staleWhileRevalidate', this.options.staleWhileRevalidate)
189✔
1768
    validateLayerNumberOption('staleIfError', this.options.staleIfError)
189✔
1769
    validateLayerNumberOption('ttlJitter', this.options.ttlJitter)
189✔
1770
    validateLayerNumberOption('refreshAhead', this.options.refreshAhead)
189✔
1771
    validatePositiveNumber('singleFlightLeaseMs', this.options.singleFlightLeaseMs)
189✔
1772
    validatePositiveNumber('singleFlightTimeoutMs', this.options.singleFlightTimeoutMs)
189✔
1773
    validatePositiveNumber('singleFlightPollMs', this.options.singleFlightPollMs)
189✔
1774
    validatePositiveNumber('singleFlightRenewIntervalMs', this.options.singleFlightRenewIntervalMs)
189✔
1775
    validatePositiveNumber('backgroundRefreshTimeoutMs', this.options.backgroundRefreshTimeoutMs)
189✔
1776
    if (this.options.snapshotMaxBytes !== false) {
189✔
1777
      validatePositiveNumber('snapshotMaxBytes', this.options.snapshotMaxBytes)
187✔
1778
    }
1779
    if (this.options.snapshotMaxEntries !== false) {
188✔
1780
      validatePositiveNumber('snapshotMaxEntries', this.options.snapshotMaxEntries)
187✔
1781
    }
1782
    if (this.options.invalidationMaxKeys !== false) {
188✔
1783
      validatePositiveNumber('invalidationMaxKeys', this.options.invalidationMaxKeys)
186✔
1784
    }
1785
    validateRateLimitOptions('fetcherRateLimit', this.options.fetcherRateLimit)
188✔
1786
    validateAdaptiveTtlOptions(this.options.adaptiveTtl)
188✔
1787
    validateCircuitBreakerOptions(this.options.circuitBreaker)
188✔
1788
    if (typeof this.options.generationCleanup === 'object') {
188✔
1789
      validatePositiveNumber('generationCleanup.batchSize', this.options.generationCleanup.batchSize)
3✔
1790
    }
1791
    if (this.options.generation !== undefined) {
188✔
1792
      validateNonNegativeNumber('generation', this.options.generation)
4✔
1793
    }
1794
  }
1795

1796
  private validateWriteOptions(options: CacheWriteOptions | undefined): void {
1797
    if (!options) {
335✔
1798
      return
220✔
1799
    }
1800

1801
    validateLayerNumberOption('options.ttl', options.ttl)
115✔
1802
    validateLayerNumberOption('options.negativeTtl', options.negativeTtl)
115✔
1803
    validateLayerNumberOption('options.staleWhileRevalidate', options.staleWhileRevalidate)
115✔
1804
    validateLayerNumberOption('options.staleIfError', options.staleIfError)
115✔
1805
    validateLayerNumberOption('options.ttlJitter', options.ttlJitter)
115✔
1806
    validateLayerNumberOption('options.refreshAhead', options.refreshAhead)
115✔
1807
    validateTtlPolicy('options.ttlPolicy', options.ttlPolicy)
115✔
1808
    validateAdaptiveTtlOptions(options.adaptiveTtl)
115✔
1809
    validateCircuitBreakerOptions(options.circuitBreaker)
115✔
1810
    validateRateLimitOptions('options.fetcherRateLimit', options.fetcherRateLimit)
115✔
1811
    validateTags(options.tags)
115✔
1812
  }
1813

1814
  private assertActive(operation: string): void {
1815
    if (this.isDisconnecting) {
761✔
1816
      throw new Error(`CacheStack is disconnecting; cannot perform ${operation}.`)
5✔
1817
    }
1818
  }
1819

1820
  private async awaitStartup(operation: string): Promise<void> {
1821
    this.assertActive(operation)
367✔
1822
    await this.startup
367✔
1823
    this.assertActive(operation)
362✔
1824
  }
1825

1826
  private async applyFreshReadPolicies<T>(
1827
    key: string,
1828
    hit: Extract<ReadHit<T>, { found: true }>,
1829
    options: CacheGetOptions | undefined,
1830
    fetcher?: () => Promise<T>
1831
  ): Promise<void> {
1832
    const refreshAhead =
1833
      this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0
63!
1834
    const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0
63✔
1835

1836
    if ((options?.slidingTtl ?? false) && isStoredValueEnvelope(hit.stored)) {
63✔
1837
      const refreshed = refreshStoredEnvelope(hit.stored)
3✔
1838
      const ttl = remainingStoredTtlSeconds(refreshed)
3✔
1839
      for (let index = 0; index <= hit.layerIndex; index += 1) {
3✔
1840
        const layer = this.layers[index]
4✔
1841
        if (!layer || this.shouldSkipLayer(layer)) {
4!
1842
          continue
×
1843
        }
1844

1845
        try {
4✔
1846
          await layer.set(key, refreshed, ttl)
4✔
1847
        } catch (error) {
1848
          await this.handleLayerFailure(layer, 'sliding-ttl', error)
×
1849
        }
1850
      }
1851
    }
1852

1853
    if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
63✔
1854
      this.scheduleBackgroundRefresh(key, fetcher, options)
1✔
1855
    }
1856
  }
1857

1858
  private shouldSkipLayer(layer: CacheLayer): boolean {
1859
    const degradedUntil = this.layerDegradedUntil.get(layer.name)
572✔
1860
    return degradedUntil !== undefined && degradedUntil > Date.now()
572✔
1861
  }
1862

1863
  private async handleLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<null> {
1864
    if (!this.isGracefulDegradationEnabled()) {
7✔
1865
      throw error
1✔
1866
    }
1867

1868
    const retryAfterMs =
1869
      typeof this.options.gracefulDegradation === 'object'
6✔
1870
        ? (this.options.gracefulDegradation.retryAfterMs ?? 10_000)
2!
1871
        : 10_000
1872

1873
    this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs)
7✔
1874
    this.metricsCollector.increment('degradedOperations')
7✔
1875
    this.logger.warn?.('layer-degraded', { layer: layer.name, operation, error: this.formatError(error) })
7✔
1876
    this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) })
7✔
1877
    return null
7✔
1878
  }
1879

1880
  private async reportRecoverableLayerFailure(layer: CacheLayer, operation: string, error: unknown): Promise<void> {
1881
    if (this.isGracefulDegradationEnabled()) {
3✔
1882
      await this.handleLayerFailure(layer, operation, error)
2✔
1883
      return
2✔
1884
    }
1885

1886
    this.logger.warn?.('layer-operation-failed', { layer: layer.name, operation, error: this.formatError(error) })
1✔
1887
    this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) })
3✔
1888
  }
1889

1890
  private isGracefulDegradationEnabled(): boolean {
1891
    return Boolean(this.options.gracefulDegradation)
10✔
1892
  }
1893

1894
  private recordCircuitFailure(key: string, options: CacheCircuitBreakerOptions | undefined, error: unknown): void {
1895
    if (!options) {
12✔
1896
      return
8✔
1897
    }
1898

1899
    this.circuitBreakerManager.recordFailure(key, options)
4✔
1900
    if (this.circuitBreakerManager.isOpen(key)) {
4!
1901
      this.metricsCollector.increment('circuitBreakerTrips')
4✔
1902
    }
1903
    this.emitError('fetch', { key, error: this.formatError(error) })
4✔
1904
  }
1905

1906
  private isNegativeStoredValue(stored: unknown): boolean {
1907
    return isStoredValueEnvelope(stored) && stored.kind === 'empty'
70✔
1908
  }
1909

1910
  private emitError(operation: string, context: Record<string, unknown>): void {
1911
    this.logger.error?.(operation, context)
15✔
1912
    if (this.listenerCount('error') > 0) {
15✔
1913
      this.emit('error', { operation, ...context })
5✔
1914
    }
1915
  }
1916

1917
  private isCacheSnapshotEntries(value: unknown): value is CacheSnapshotEntry[] {
1918
    return (
8✔
1919
      Array.isArray(value) &&
15✔
1920
      value.every((entry) => {
1921
        if (!entry || typeof entry !== 'object') {
7✔
1922
          return false
1✔
1923
        }
1924

1925
        const candidate = entry as Partial<CacheSnapshotEntry>
6✔
1926
        return (
6✔
1927
          typeof candidate.key === 'string' &&
21✔
1928
          (candidate.ttl === undefined ||
1929
            (typeof candidate.ttl === 'number' && Number.isFinite(candidate.ttl) && candidate.ttl >= 0))
1930
        )
1931
      })
1932
    )
1933
  }
1934

1935
  private sanitizeSnapshotValue(value: unknown): unknown {
1936
    return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value))
4✔
1937
  }
1938

1939
  private snapshotMaxBytes(): number | false {
1940
    return this.options.snapshotMaxBytes === false
9✔
1941
      ? false
1942
      : (this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES)
14✔
1943
  }
1944

1945
  private snapshotMaxEntries(): number | false {
1946
    return this.options.snapshotMaxEntries === false
6✔
1947
      ? false
1948
      : (this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES)
9✔
1949
  }
1950

1951
  private invalidationMaxKeys(): number | false {
1952
    return this.options.invalidationMaxKeys === false
38✔
1953
      ? false
1954
      : (this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS)
64✔
1955
  }
1956

1957
  private async collectKeysForTag(tag: string): Promise<string[]> {
1958
    const keys = new Set<string>()
15✔
1959

1960
    if (this.tagIndex.forEachKeyForTag) {
15✔
1961
      await this.tagIndex.forEachKeyForTag(tag, async (key) => {
14✔
1962
        keys.add(key)
18✔
1963
        this.assertWithinInvalidationKeyLimit(keys.size)
18✔
1964
      })
1965
      return [...keys]
12✔
1966
    }
1967

1968
    for (const key of await this.tagIndex.keysForTag(tag)) {
1✔
1969
      keys.add(key)
1✔
1970
      this.assertWithinInvalidationKeyLimit(keys.size)
1✔
1971
    }
1972

1973
    return [...keys]
1✔
1974
  }
1975

1976
  private assertWithinInvalidationKeyLimit(size: number): void {
1977
    const maxKeys = this.invalidationMaxKeys()
23✔
1978
    if (maxKeys !== false && size > maxKeys) {
23✔
1979
      throw new Error(`Invalidation matched too many keys (${size} > ${maxKeys}).`)
3✔
1980
    }
1981
  }
1982

1983
  private async visitExportEntries(
1984
    maxEntries: number | false,
1985
    visitor: (entry: CacheSnapshotEntry) => Promise<void> | void
1986
  ): Promise<void> {
1987
    const exported = new Set<string>()
6✔
1988

1989
    for (const layer of this.layers) {
6✔
1990
      if (!layer.keys && !layer.forEachKey) {
8✔
1991
        continue
1✔
1992
      }
1993

1994
      const visitKey = async (key: string): Promise<void> => {
7✔
1995
        const exportedKey = this.stripQualifiedKey(key)
11✔
1996
        if (exported.has(exportedKey)) {
11✔
1997
          return
3✔
1998
        }
1999

2000
        const stored = await this.readLayerEntry(layer, key)
8✔
2001
        if (stored === null) {
8✔
2002
          return
1✔
2003
        }
2004

2005
        exported.add(exportedKey)
7✔
2006
        if (maxEntries !== false && exported.size > maxEntries) {
7✔
2007
          throw new Error(`Snapshot export exceeds snapshotMaxEntries limit (${exported.size} > ${maxEntries}).`)
1✔
2008
        }
2009
        await visitor({
6✔
2010
          key: exportedKey,
2011
          value: stored,
2012
          ttl: remainingStoredTtlSeconds(stored)
2013
        })
2014
      }
2015

2016
      if (layer.forEachKey) {
7✔
2017
        await layer.forEachKey(visitKey)
5✔
2018
        continue
4✔
2019
      }
2020

2021
      const keys = await layer.keys?.()
2✔
2022
      for (const key of keys ?? []) {
2!
2023
        await visitKey(key)
2✔
2024
      }
2025
    }
2026
  }
2027
}
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