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

flyingsquirrel0419 / layercache / 29460983628

16 Jul 2026 12:16AM UTC coverage: 96.103% (+0.2%) from 95.882%
29460983628

Pull #106

github

web-flow
Merge 50c42a29d into e5107fec2
Pull Request #106: Prepare Layercache 4.0.0 release

2023 of 2171 branches covered (93.18%)

Branch coverage included in aggregate %.

433 of 442 new or added lines in 25 files covered. (97.96%)

2 existing lines in 2 files now uncovered.

3550 of 3628 relevant lines covered (97.85%)

391.95 hits per line

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

94.92
/src/internal/CacheStackReader.ts
1
import type { StampedeGuard } from '../stampede/StampedeGuard'
2
import type {
3
  CacheCircuitBreakerOptions,
4
  CacheFetcher,
5
  CacheFetcherContext,
6
  CacheGetOptions,
7
  CacheLayer,
8
  CacheLogger,
9
  CacheRateLimitOptions,
10
  CacheSingleFlightCoordinator,
11
  CacheSingleFlightExecutionOptions,
12
  CacheStackEvents,
13
  CacheTagIndex,
14
  CacheWriteOptions,
15
  LayerTtlMap
16
} from '../types'
17
import type { CacheWriteFence, CacheWriteKind } from './CacheStackLayerWriter'
18
import type { CacheStackMaintenance } from './CacheStackMaintenance'
19
import { planFreshReadPolicies, shouldStartBackgroundRefresh } from './CacheStackRuntimePolicy'
20
import type { CircuitBreakerManager } from './CircuitBreakerManager'
21
import type { FetchRateLimiter } from './FetchRateLimiter'
22
import { FetchRateLimitError } from './FetchRateLimiter'
23
import type { MetricsCollector } from './MetricsCollector'
24
import { isStoredValueEnvelope, remainingStoredTtlMs, resolveStoredValue } from './StoredValue'
25
import type { TtlResolver } from './TtlResolver'
26

27
const DEFAULT_SINGLE_FLIGHT_LEASE_MS = 30_000
15✔
28
const DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5_000
15✔
29
const DEFAULT_SINGLE_FLIGHT_POLL_MS = 50
15✔
30
const DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 30_000
15✔
31
const SINGLE_FLIGHT_BACKOFF_FACTOR = 2
15✔
32
const SINGLE_FLIGHT_BACKOFF_JITTER = 0.2
15✔
33
const SINGLE_FLIGHT_MAX_POLL_MS = 1_000
15✔
34

35
type ReadMode = 'allow-stale' | 'fresh-only'
36

37
type ReadHit<T> =
38
  | {
39
      found: true
40
      value: T | undefined
41
      stored: unknown
42
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
43
      layerIndex: number
44
      layerName: string
45
    }
46
  | { found: false; value: undefined; stored: null; state: 'miss' }
47

48
interface CacheStackReaderOptions {
49
  // Direct service objects
50
  layers: CacheLayer[]
51
  metricsCollector: MetricsCollector
52
  maintenance: CacheStackMaintenance
53
  tagIndex: CacheTagIndex
54
  circuitBreakerManager: CircuitBreakerManager
55
  fetchRateLimiter: FetchRateLimiter
56
  stampedeGuard: StampedeGuard
57
  ttlResolver: TtlResolver
58
  logger: CacheLogger
59

60
  // CacheStack method callbacks
61
  shouldSkipLayer: (layer: CacheLayer) => boolean
62
  handleLayerFailure: (layer: CacheLayer, operation: string, error: unknown) => Promise<null>
63
  emit: <K extends keyof CacheStackEvents>(event: K, data: CacheStackEvents[K]) => boolean
64
  emitError: (operation: string, context: Record<string, unknown>) => void
65
  formatError: (error: unknown) => string
66
  storeEntry: (
67
    key: string,
68
    kind: CacheWriteKind,
69
    value: unknown,
70
    options?: CacheWriteOptions,
71
    fence?: CacheWriteFence
72
  ) => Promise<boolean>
73
  recordCircuitFailure: (
74
    key: string,
75
    breakerKey: string,
76
    options: CacheCircuitBreakerOptions | undefined,
77
    error: unknown
78
  ) => void
79
  resolveLayerMs: (
80
    layerName: string,
81
    override: number | LayerTtlMap | undefined,
82
    globalDefault?: number | LayerTtlMap,
83
    fallback?: number
84
  ) => number | undefined
85
  sleep: (ms: number) => Promise<void>
86
  withTimeout: <T>(promise: Promise<T>, timeoutMs: number, createError: () => Error) => Promise<T>
87
  isDisconnecting: () => boolean
88
  isGracefulDegradationEnabled: () => boolean
89
  scheduleBackgroundRefreshDispatch: <T>(
90
    key: string,
91
    fetcher: CacheFetcher<T>,
92
    options?: CacheGetOptions,
93
    fetcherContext?: CacheFetcherContext<T>
94
  ) => void
95

96
  // Config values
97
  stampedePrevention?: boolean
98
  singleFlightCoordinator?: CacheSingleFlightCoordinator
99
  singleFlightLeaseMs?: number
100
  singleFlightTimeoutMs?: number
101
  singleFlightPollMs?: number
102
  singleFlightRenewIntervalMs?: number
103
  backgroundRefreshTimeoutMs?: number
104
  negativeCaching?: boolean
105
  cacheNullValues?: boolean
106
  refreshAhead?: number | LayerTtlMap
107
  circuitBreaker?: CacheCircuitBreakerOptions
108
  fetcherRateLimit?: CacheRateLimitOptions
109
}
110

111
export class CacheStackReader {
112
  private readonly backgroundRefreshes = new Map<string, Promise<void>>()
323✔
113
  private readonly backgroundRefreshAbort = new Map<string, boolean>()
323✔
114

115
  constructor(private readonly options: CacheStackReaderOptions) {}
323✔
116

117
  get activeRefreshCount(): number {
118
    return this.backgroundRefreshes.size
28✔
119
  }
120

121
  async getPrepared<T>(
122
    normalizedKey: string,
123
    fetcher?: CacheFetcher<T>,
124
    options?: CacheGetOptions
125
  ): Promise<T | undefined> {
126
    const operationFence = {
363✔
127
      clearEpoch: this.options.maintenance.currentClearEpoch(),
128
      keyEpoch: this.options.maintenance.currentKeyEpoch(normalizedKey)
129
    }
130
    const hit = await this.readFromLayers<T>(normalizedKey, options, 'allow-stale')
363✔
131
    if (hit.found) {
363✔
132
      this.options.ttlResolver.recordAccess(normalizedKey)
108✔
133
      if (this.isNegativeStoredValue(hit.stored)) {
108✔
134
        this.options.metricsCollector.increment('negativeCacheHits')
2✔
135
      }
136

137
      if (hit.state === 'fresh') {
108✔
138
        this.options.metricsCollector.increment('hits')
86✔
139
        await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher)
86✔
140
        return hit.value
86✔
141
      }
142

143
      if (hit.state === 'stale-while-revalidate') {
22✔
144
        this.options.metricsCollector.increment('hits')
17✔
145
        this.options.metricsCollector.increment('staleHits')
17✔
146
        this.options.emit('stale-serve', { key: normalizedKey, state: hit.state, layer: hit.layerName })
17✔
147
        if (fetcher) {
17✔
148
          this.scheduleBackgroundRefresh(normalizedKey, fetcher, options, this.createFetcherContext(normalizedKey, hit))
15✔
149
        }
150
        return hit.value
17✔
151
      }
152

153
      if (!fetcher) {
5✔
154
        this.options.metricsCollector.increment('hits')
1✔
155
        this.options.metricsCollector.increment('staleHits')
1✔
156
        this.options.emit('stale-serve', { key: normalizedKey, state: hit.state, layer: hit.layerName })
1✔
157
        return hit.value
1✔
158
      }
159

160
      try {
4✔
161
        return await this.fetchWithGuards(
4✔
162
          normalizedKey,
163
          fetcher,
164
          options,
165
          operationFence.clearEpoch,
166
          operationFence.keyEpoch,
167
          false,
168
          this.createFetcherContext(normalizedKey, hit)
169
        )
170
      } catch (error) {
171
        this.options.metricsCollector.increment('staleHits')
3✔
172
        this.options.metricsCollector.increment('refreshErrors')
3✔
173
        this.options.logger.debug?.('stale-if-error', {
3✔
174
          key: normalizedKey,
175
          error: this.options.formatError(error)
176
        })
177
        return hit.value
3✔
178
      }
179
    }
180

181
    this.options.metricsCollector.increment('misses')
255✔
182
    if (!fetcher) {
255✔
183
      return undefined
78✔
184
    }
185

186
    return this.fetchWithGuards(
177✔
187
      normalizedKey,
188
      fetcher,
189
      options,
190
      operationFence.clearEpoch,
191
      operationFence.keyEpoch,
192
      true,
193
      {
194
        key: normalizedKey,
195
        currentValue: undefined,
196
        state: 'miss'
197
      }
198
    )
199
  }
200

201
  async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
202
    if (this.options.shouldSkipLayer(layer)) {
560✔
203
      return null
3✔
204
    }
205

206
    if (layer.getEntry) {
557✔
207
      try {
473✔
208
        return await layer.getEntry(key)
473✔
209
      } catch (error) {
210
        return this.options.handleLayerFailure(layer, 'read', error)
2✔
211
      }
212
    }
213

214
    try {
84✔
215
      return await layer.get(key)
84✔
216
    } catch (error) {
217
      return this.options.handleLayerFailure(layer, 'read', error)
3✔
218
    }
219
  }
220

221
  async backfill(
222
    key: string,
223
    stored: unknown,
224
    upToIndex: number,
225
    options?: CacheGetOptions,
226
    fence: CacheWriteFence = {
133✔
227
      clearEpoch: this.options.maintenance.currentClearEpoch(),
228
      keyEpoch: this.options.maintenance.currentKeyEpoch(key)
229
    }
230
  ): Promise<void> {
231
    if (upToIndex < 0) {
133✔
232
      return
112✔
233
    }
234

235
    const operations: Array<() => Promise<void>> = []
21✔
236

237
    for (let index = 0; index <= upToIndex; index += 1) {
21✔
238
      const layer = this.options.layers[index]
25✔
239
      if (!layer || this.options.shouldSkipLayer(layer)) {
25✔
240
        continue
4✔
241
      }
242

243
      const ttl =
244
        remainingStoredTtlMs(stored) ??
21✔
245
        this.options.resolveLayerMs(layer.name, options?.ttl, undefined, layer.defaultTtl)
246
      operations.push(async () => {
25✔
247
        try {
21✔
248
          await layer.set(key, stored, ttl)
21✔
249
        } catch (error) {
250
          await this.options.handleLayerFailure(layer, 'backfill', error)
1✔
251
          return
1✔
252
        }
253
        this.options.metricsCollector.increment('backfills')
20✔
254
        this.options.logger.debug?.('backfill', { key, layer: layer.name })
20✔
255
        this.options.emit('backfill', { key, layer: layer.name })
21✔
256
      })
257
    }
258

259
    const layers = this.options.layers.slice(0, upToIndex + 1).filter((layer) => Boolean(layer))
25✔
260
    const executeBackfills = async (): Promise<void> => {
25✔
261
      const pending: Array<Promise<void>> = []
21✔
262
      for (const operation of operations) pending.push(operation())
21✔
263
      await Promise.all(pending)
21✔
264
    }
265
    const cleanupBackfills = async (): Promise<void> => {
25✔
NEW
266
      const pending: Array<Promise<void>> = []
×
NEW
267
      for (const layer of layers) pending.push(layer.delete(key))
×
NEW
268
      await Promise.all(pending)
×
269
    }
270
    await this.options.maintenance.runFencedWrite(
25✔
271
      key,
272
      fence.clearEpoch,
273
      fence.keyEpoch,
274
      executeBackfills,
275
      cleanupBackfills
276
    )
277
  }
278

279
  abortAllRefreshes(): void {
280
    for (const key of this.backgroundRefreshAbort.keys()) {
29✔
281
      this.backgroundRefreshAbort.set(key, true)
1✔
282
    }
283
  }
284

285
  getAllRefreshPromises(): Promise<void>[] {
286
    return [...this.backgroundRefreshes.values()]
31✔
287
  }
288

289
  private async readFromLayers<T>(
290
    key: string,
291
    options: CacheGetOptions | undefined,
292
    mode: ReadMode
293
  ): Promise<ReadHit<T>> {
294
    const readFence = {
491✔
295
      clearEpoch: this.options.maintenance.currentClearEpoch(),
296
      keyEpoch: this.options.maintenance.currentKeyEpoch(key)
297
    }
298
    let sawRetainableValue = false
491✔
299

300
    for (let index = 0; index < this.options.layers.length; index += 1) {
491✔
301
      const layer = this.options.layers[index]
513✔
302
      if (!layer) continue
513!
303
      const readStart = performance.now()
513✔
304
      const stored = await this.readLayerEntry(layer, key)
513✔
305
      const readDuration = performance.now() - readStart
513✔
306
      this.options.metricsCollector.recordLatency(layer.name, readDuration)
513✔
307
      if (stored === null) {
513✔
308
        this.options.metricsCollector.incrementLayer('missesByLayer', layer.name)
380✔
309
        continue
380✔
310
      }
311

312
      const resolved = resolveStoredValue<T>(stored)
133✔
313
      if (resolved.state === 'expired') {
133✔
314
        await layer.delete(key)
2✔
315
        continue
2✔
316
      }
317

318
      sawRetainableValue = true
131✔
319

320
      if (mode === 'fresh-only' && resolved.state !== 'fresh') {
131✔
321
        continue
19✔
322
      }
323

324
      await this.options.tagIndex.touch(key)
112✔
325
      await this.backfill(key, stored, index - 1, options, readFence)
112✔
326
      this.options.metricsCollector.incrementLayer('hitsByLayer', layer.name)
112✔
327
      this.options.logger.debug?.('hit', { key, layer: layer.name, state: resolved.state })
112✔
328
      this.options.emit('hit', {
513✔
329
        key,
330
        layer: layer.name,
331
        state: resolved.state as CacheStackEvents['hit']['state']
332
      })
333
      return {
513✔
334
        found: true,
335
        value: this.isNegativeStoredValue(stored) ? undefined : (resolved.value as T),
112✔
336
        stored,
337
        state: resolved.state,
338
        layerIndex: index,
339
        layerName: layer.name
340
      }
341
    }
342

343
    if (!sawRetainableValue) {
379✔
344
      await this.options.tagIndex.remove(key)
361✔
345
    }
346

347
    this.options.logger.debug?.('miss', { key, mode })
379✔
348
    this.options.emit('miss', { key, mode })
491✔
349
    return { found: false, value: undefined, stored: null, state: 'miss' }
491✔
350
  }
351

352
  private async fetchWithGuards<T>(
353
    key: string,
354
    fetcher: CacheFetcher<T>,
355
    options?: CacheGetOptions,
356
    expectedClearEpoch?: number,
357
    expectedKeyEpoch?: number,
358
    initialMissConfirmed = false,
198✔
359
    fetcherContext: CacheFetcherContext<T> = {
198✔
360
      key,
361
      currentValue: undefined,
362
      state: 'miss'
363
    }
364
  ): Promise<T | undefined> {
365
    const clearEpoch = expectedClearEpoch ?? this.options.maintenance.currentClearEpoch()
198!
366
    const keyEpoch = expectedKeyEpoch ?? this.options.maintenance.currentKeyEpoch(key)
198!
367
    const fetchTask = async (): Promise<T | undefined> => {
198✔
368
      const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator)
117✔
369
      if (shouldRecheckFreshLayers) {
117✔
370
        const secondHit = await this.readFromLayers<T>(key, options, 'fresh-only')
111✔
371
        if (secondHit.found) {
111✔
372
          this.options.metricsCollector.increment('hits')
2✔
373
          return secondHit.value
2✔
374
        }
375
      }
376

377
      return this.fetchAndPopulate(key, fetcher, options, clearEpoch, keyEpoch, fetcherContext)
115✔
378
    }
379

380
    const singleFlightTask = async (): Promise<T | undefined> => {
198✔
381
      if (!this.options.singleFlightCoordinator) {
124✔
382
        return fetchTask()
111✔
383
      }
384

385
      try {
13✔
386
        return await this.options.singleFlightCoordinator.execute(
13✔
387
          key,
388
          this.resolveSingleFlightOptions(),
389
          fetchTask,
390
          () => this.waitForFreshValue(key, fetcher, options, clearEpoch, keyEpoch, fetcherContext)
6✔
391
        )
392
      } catch (error) {
393
        if (!this.options.isGracefulDegradationEnabled()) {
4✔
394
          throw error
2✔
395
        }
396

397
        this.options.metricsCollector.increment('degradedOperations')
2✔
398
        this.options.logger.warn?.('single-flight-coordinator-degraded', {
2✔
399
          key,
400
          error: this.options.formatError(error)
401
        })
402
        this.options.emitError('single-flight', {
4✔
403
          key,
404
          degraded: true,
405
          error: this.options.formatError(error)
406
        })
407
        return fetchTask()
4✔
408
      }
409
    }
410

411
    if (this.options.stampedePrevention === false) {
198✔
412
      return singleFlightTask()
3✔
413
    }
414

415
    return this.options.stampedeGuard.execute(key, singleFlightTask)
195✔
416
  }
417

418
  private async waitForFreshValue<T>(
419
    key: string,
420
    fetcher: CacheFetcher<T>,
421
    options?: CacheGetOptions,
422
    expectedClearEpoch?: number,
423
    expectedKeyEpoch?: number,
424
    fetcherContext: CacheFetcherContext<T> = {
7✔
425
      key,
426
      currentValue: undefined,
427
      state: 'miss'
428
    },
429
    deadline?: number,
430
    coordinatorRetries = 0
7✔
431
  ): Promise<T | undefined> {
432
    const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS
7✔
433
    const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
7✔
434
    const operationDeadline = deadline ?? Date.now() + timeoutMs
7✔
435
    let nextPollMs = pollIntervalMs
7✔
436

437
    this.options.metricsCollector.increment('singleFlightWaits')
7✔
438
    this.options.emit('stampede-dedupe', { key })
7✔
439

440
    while (Date.now() < operationDeadline) {
7✔
441
      const hit = await this.readFromLayers<T>(key, options, 'fresh-only')
17✔
442
      if (hit.found) {
17✔
443
        this.options.metricsCollector.increment('hits')
2✔
444
        return hit.value
2✔
445
      }
446
      const remainingMs = operationDeadline - Date.now()
15✔
447
      if (remainingMs <= 0) {
15!
448
        break
×
449
      }
450
      const delayMs = Math.min(this.jitterSingleFlightPoll(nextPollMs), remainingMs)
15✔
451
      await this.options.sleep(delayMs)
15✔
452
      nextPollMs = Math.min(nextPollMs * SINGLE_FLIGHT_BACKOFF_FACTOR, SINGLE_FLIGHT_MAX_POLL_MS, timeoutMs)
15✔
453
    }
454

455
    if (!this.options.singleFlightCoordinator || coordinatorRetries >= 1) {
5✔
456
      throw new Error(`Single-flight wait timed out after ${timeoutMs}ms for key "${key}".`)
1✔
457
    }
458

459
    return this.options.singleFlightCoordinator.execute(
4✔
460
      key,
461
      this.resolveSingleFlightOptions(),
462
      () => this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext),
3✔
463
      () =>
464
        this.waitForFreshValue(
1✔
465
          key,
466
          fetcher,
467
          options,
468
          expectedClearEpoch,
469
          expectedKeyEpoch,
470
          fetcherContext,
471
          operationDeadline,
472
          coordinatorRetries + 1
473
        )
474
    )
475
  }
476

477
  private jitterSingleFlightPoll(delayMs: number): number {
478
    const jitterRange = delayMs * SINGLE_FLIGHT_BACKOFF_JITTER
15✔
479
    return Math.max(1, Math.round(delayMs - jitterRange + Math.random() * jitterRange * 2))
15✔
480
  }
481

482
  private async fetchAndPopulate<T>(
483
    key: string,
484
    fetcher: CacheFetcher<T>,
485
    options?: CacheGetOptions,
486
    expectedClearEpoch?: number,
487
    expectedKeyEpoch?: number,
488
    fetcherContext: CacheFetcherContext<T> = {
118✔
489
      key,
490
      currentValue: undefined,
491
      state: 'miss'
492
    }
493
  ): Promise<T | undefined> {
494
    const circuitBreakerOptions = options?.circuitBreaker ?? this.options.circuitBreaker
118✔
495
    const breakerKey = this.resolveCircuitBreakerKey(key, circuitBreakerOptions)
118✔
496
    this.options.circuitBreakerManager.assertClosed(breakerKey, circuitBreakerOptions)
118✔
497
    this.options.metricsCollector.increment('fetches')
118✔
498
    const fetchStart = Date.now()
118✔
499
    let fetched: T
500

501
    try {
118✔
502
      fetched = await this.options.fetchRateLimiter.schedule(
118✔
503
        options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
226✔
504
        { key, fetcher },
505
        () => fetcher(fetcherContext)
114✔
506
      )
507
      this.options.circuitBreakerManager.recordSuccess(breakerKey)
96✔
508
      this.options.logger.debug?.('fetch', { key, durationMs: Date.now() - fetchStart })
96✔
509
    } catch (error) {
510
      if (!(error instanceof FetchRateLimitError)) {
13!
511
        this.options.recordCircuitFailure(key, breakerKey, circuitBreakerOptions, error)
13✔
512
      }
513
      throw error
13✔
514
    }
515

516
    if (fetched === undefined || (fetched === null && !this.shouldCacheNullValues(options))) {
96✔
517
      if (!this.shouldNegativeCache(options)) {
6✔
518
        return undefined
2✔
519
      }
520

521
      if (this.options.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
4!
UNCOV
522
        this.options.logger.debug?.('skip-negative-store-after-invalidation', {
×
523
          key,
524
          expectedClearEpoch,
525
          clearEpoch: this.options.maintenance.currentClearEpoch(),
526
          expectedKeyEpoch,
527
          keyEpoch: this.options.maintenance.currentKeyEpoch(key)
528
        })
NEW
529
        return undefined
×
530
      }
531

532
      await this.options.storeEntry(key, 'empty', null, options, {
4✔
533
        clearEpoch: expectedClearEpoch ?? this.options.maintenance.currentClearEpoch(),
4!
534
        keyEpoch: expectedKeyEpoch ?? this.options.maintenance.currentKeyEpoch(key)
6!
535
      })
536
      return undefined
4✔
537
    }
538

539
    // Conditional caching: skip storage if shouldCache returns false
540
    if (options?.shouldCache) {
90✔
541
      try {
14✔
542
        if (!options.shouldCache(fetched)) {
14✔
543
          return fetched
5✔
544
        }
545
      } catch (error) {
546
        this.options.logger.warn?.('shouldCache-error', {
2✔
547
          key,
548
          error: this.options.formatError(error)
549
        })
550
        return fetched
2✔
551
      }
552
    }
553

554
    if (this.options.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
83✔
555
      this.options.logger.debug?.('skip-store-after-invalidation', {
4✔
556
        key,
557
        expectedClearEpoch,
558
        clearEpoch: this.options.maintenance.currentClearEpoch(),
559
        expectedKeyEpoch,
560
        keyEpoch: this.options.maintenance.currentKeyEpoch(key)
561
      })
562
      return fetched
4✔
563
    }
564

565
    await this.options.storeEntry(key, 'value', fetched, options, {
79✔
566
      clearEpoch: expectedClearEpoch ?? this.options.maintenance.currentClearEpoch(),
79!
567
      keyEpoch: expectedKeyEpoch ?? this.options.maintenance.currentKeyEpoch(key)
118!
568
    })
569
    return fetched
74✔
570
  }
571

572
  private resolveCircuitBreakerKey(key: string, options: CacheCircuitBreakerOptions | undefined): string {
573
    if (!options) {
118✔
574
      return `key:${key}`
105✔
575
    }
576

577
    if (options.breakerKey) {
13✔
578
      return `custom:${options.breakerKey}`
3✔
579
    }
580

581
    if (options.scope === 'shared') {
10✔
582
      return 'scope:shared'
1✔
583
    }
584

585
    return `key:${key}`
9✔
586
  }
587

588
  runScheduleBackgroundRefresh<T>(
589
    key: string,
590
    fetcher: CacheFetcher<T>,
591
    options?: CacheGetOptions,
592
    fetcherContext?: CacheFetcherContext<T>
593
  ): void {
594
    this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext)
5✔
595
  }
596

597
  private scheduleBackgroundRefresh<T>(
598
    key: string,
599
    fetcher: CacheFetcher<T>,
600
    options?: CacheGetOptions,
601
    fetcherContext: CacheFetcherContext<T> = {
20✔
602
      key,
603
      currentValue: undefined,
604
      state: 'miss'
605
    }
606
  ): void {
607
    if (
20✔
608
      !shouldStartBackgroundRefresh({
609
        isDisconnecting: this.options.isDisconnecting(),
610
        hasRefreshInFlight: this.backgroundRefreshes.has(key)
611
      })
612
    ) {
613
      return
3✔
614
    }
615

616
    const clearEpoch = this.options.maintenance.currentClearEpoch()
17✔
617
    const keyEpoch = this.options.maintenance.currentKeyEpoch(key)
17✔
618
    this.backgroundRefreshAbort.set(key, false)
17✔
619
    const refresh = (async () => {
17✔
620
      this.options.metricsCollector.increment('refreshes')
17✔
621
      try {
17✔
622
        if (this.backgroundRefreshAbort.get(key)) return
17!
623
        await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch, fetcherContext)
17✔
624
      } catch (error) {
625
        if (this.backgroundRefreshAbort.get(key)) return
1!
626
        this.options.metricsCollector.increment('refreshErrors')
1✔
627
        this.options.logger.warn?.('background-refresh-error', {
1✔
628
          key,
629
          error: this.options.formatError(error)
630
        })
631
      } finally {
632
        if (this.backgroundRefreshes.get(key) === refresh) {
13!
633
          this.backgroundRefreshes.delete(key)
13✔
634
          this.backgroundRefreshAbort.delete(key)
13✔
635
        }
636
      }
637
    })()
638

639
    this.backgroundRefreshes.set(key, refresh)
17✔
640
    const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS
17✔
641
    void this.options
20✔
642
      .withTimeout(refresh, timeoutMs, () => {
643
        return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`)
3✔
644
      })
645
      .catch((error) => {
646
        if (this.backgroundRefreshAbort.get(key)) return
3!
647
        this.options.metricsCollector.increment('refreshErrors')
3✔
648
        this.options.logger.warn?.('background-refresh-timeout', {
3✔
649
          key,
650
          error: this.options.formatError(error)
651
        })
652
      })
653
  }
654

655
  private async runBackgroundRefresh<T>(
656
    key: string,
657
    fetcher: CacheFetcher<T>,
658
    options?: CacheGetOptions,
659
    expectedClearEpoch?: number,
660
    expectedKeyEpoch?: number,
661
    fetcherContext: CacheFetcherContext<T> = {
17✔
662
      key,
663
      currentValue: undefined,
664
      state: 'miss'
665
    }
666
  ): Promise<void> {
667
    await this.fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, false, fetcherContext)
17✔
668
  }
669

670
  async runApplyFreshReadPolicies<T>(
671
    key: string,
672
    hit: {
673
      found: true
674
      value: T | null
675
      stored: unknown
676
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
677
      layerIndex: number
678
      layerName: string
679
    },
680
    options: CacheGetOptions | undefined,
681
    fetcher?: CacheFetcher<T>
682
  ): Promise<void> {
683
    return this.applyFreshReadPolicies(key, hit as Extract<ReadHit<T>, { found: true }>, options, fetcher)
4✔
684
  }
685

686
  private async applyFreshReadPolicies<T>(
687
    key: string,
688
    hit: Extract<ReadHit<T>, { found: true }>,
689
    options: CacheGetOptions | undefined,
690
    fetcher?: CacheFetcher<T>
691
  ): Promise<void> {
692
    const plan = planFreshReadPolicies({
90✔
693
      stored: hit.stored,
694
      hasFetcher: Boolean(fetcher),
695
      slidingTtl: options?.slidingTtl ?? false,
173✔
696
      refreshAheadMs:
697
        this.options.resolveLayerMs(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0
98✔
698
    })
699

700
    if (plan.refreshedStored) {
90✔
701
      for (let index = 0; index <= hit.layerIndex; index += 1) {
7✔
702
        const layer = this.options.layers[index]
10✔
703
        if (!layer || this.options.shouldSkipLayer(layer)) {
10✔
704
          continue
1✔
705
        }
706

707
        try {
9✔
708
          await layer.set(key, plan.refreshedStored, plan.refreshedStoredTtl)
9✔
709
        } catch (error) {
710
          await this.options.handleLayerFailure(layer, 'sliding-ttl', error)
1✔
711
        }
712
      }
713
    }
714

715
    if (fetcher && plan.shouldScheduleBackgroundRefresh) {
90✔
716
      this.options.scheduleBackgroundRefreshDispatch(key, fetcher, options, this.createFetcherContext(key, hit))
2✔
717
    }
718
  }
719

720
  private createFetcherContext<T>(key: string, hit: Extract<ReadHit<T>, { found: true }>): CacheFetcherContext<T> {
721
    return {
21✔
722
      key,
723
      currentValue: hit.value,
724
      state: hit.state,
725
      layer: hit.layerName
726
    }
727
  }
728

729
  private resolveSingleFlightOptions(): CacheSingleFlightExecutionOptions {
730
    return {
17✔
731
      leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
33✔
732
      waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
24✔
733
      pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
24✔
734
      renewIntervalMs: this.options.singleFlightRenewIntervalMs
735
    }
736
  }
737

738
  private shouldNegativeCache(options?: CacheGetOptions): boolean {
739
    return options?.negativeCache ?? this.options.negativeCaching ?? false
6✔
740
  }
741

742
  private shouldCacheNullValues(options?: CacheGetOptions): boolean {
743
    return options?.cacheNullValues ?? this.options.cacheNullValues ?? true
11✔
744
  }
745

746
  private isNegativeStoredValue(stored: unknown): boolean {
747
    return isStoredValueEnvelope(stored) && stored.kind === 'empty'
220✔
748
  }
749
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc