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

flyingsquirrel0419 / layercache / 25602011727

09 May 2026 01:13PM UTC coverage: 95.636% (+0.002%) from 95.634%
25602011727

Pull #91

github

web-flow
Merge 770e0822d into 72606df0e
Pull Request #91: Merge cache API, invalidation, and concurrency updates

1785 of 1923 branches covered (92.82%)

Branch coverage included in aggregate %.

141 of 145 new or added lines in 8 files covered. (97.24%)

20 existing lines in 2 files now uncovered.

3212 of 3302 relevant lines covered (97.27%)

330.83 hits per line

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

97.44
/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 { 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 type { MetricsCollector } from './MetricsCollector'
23
import { isStoredValueEnvelope, remainingStoredTtlMs, resolveStoredValue } from './StoredValue'
24
import type { TtlResolver } from './TtlResolver'
25

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

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

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

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

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

89
  // Config values
90
  stampedePrevention?: boolean
91
  singleFlightCoordinator?: CacheSingleFlightCoordinator
92
  singleFlightLeaseMs?: number
93
  singleFlightTimeoutMs?: number
94
  singleFlightPollMs?: number
95
  singleFlightRenewIntervalMs?: number
96
  backgroundRefreshTimeoutMs?: number
97
  negativeCaching?: boolean
98
  cacheNullValues?: boolean
99
  refreshAhead?: number | LayerTtlMap
100
  circuitBreaker?: CacheCircuitBreakerOptions
101
  fetcherRateLimit?: CacheRateLimitOptions
102
}
103

104
export class CacheStackReader {
105
  private readonly backgroundRefreshes = new Map<string, Promise<void>>()
306✔
106
  private readonly backgroundRefreshAbort = new Map<string, boolean>()
306✔
107

108
  constructor(private readonly options: CacheStackReaderOptions) {}
306✔
109

110
  get activeRefreshCount(): number {
111
    return this.backgroundRefreshes.size
33✔
112
  }
113

114
  async getPrepared<T>(normalizedKey: string, fetcher?: CacheFetcher<T>, options?: CacheGetOptions): Promise<T | null> {
115
    const hit = await this.readFromLayers<T>(normalizedKey, options, 'allow-stale')
346✔
116
    if (hit.found) {
346✔
117
      this.options.ttlResolver.recordAccess(normalizedKey)
107✔
118
      if (this.isNegativeStoredValue(hit.stored)) {
107✔
119
        this.options.metricsCollector.increment('negativeCacheHits')
2✔
120
      }
121

122
      if (hit.state === 'fresh') {
107✔
123
        this.options.metricsCollector.increment('hits')
85✔
124
        await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher)
85✔
125
        return hit.value
85✔
126
      }
127

128
      if (hit.state === 'stale-while-revalidate') {
22✔
129
        this.options.metricsCollector.increment('hits')
17✔
130
        this.options.metricsCollector.increment('staleHits')
17✔
131
        this.options.emit('stale-serve', { key: normalizedKey, state: hit.state, layer: hit.layerName })
17✔
132
        if (fetcher) {
17✔
133
          this.scheduleBackgroundRefresh(normalizedKey, fetcher, options, this.createFetcherContext(normalizedKey, hit))
15✔
134
        }
135
        return hit.value
17✔
136
      }
137

138
      if (!fetcher) {
5✔
139
        this.options.metricsCollector.increment('hits')
1✔
140
        this.options.metricsCollector.increment('staleHits')
1✔
141
        this.options.emit('stale-serve', { key: normalizedKey, state: hit.state, layer: hit.layerName })
1✔
142
        return hit.value
1✔
143
      }
144

145
      try {
4✔
146
        return await this.fetchWithGuards(
4✔
147
          normalizedKey,
148
          fetcher,
149
          options,
150
          undefined,
151
          undefined,
152
          false,
153
          this.createFetcherContext(normalizedKey, hit)
154
        )
155
      } catch (error) {
156
        this.options.metricsCollector.increment('staleHits')
3✔
157
        this.options.metricsCollector.increment('refreshErrors')
3✔
158
        this.options.logger.debug?.('stale-if-error', {
3✔
159
          key: normalizedKey,
160
          error: this.options.formatError(error)
161
        })
162
        return hit.value
3✔
163
      }
164
    }
165

166
    this.options.metricsCollector.increment('misses')
239✔
167
    if (!fetcher) {
239✔
168
      return null
74✔
169
    }
170

171
    return this.fetchWithGuards(normalizedKey, fetcher, options, undefined, undefined, true, {
165✔
172
      key: normalizedKey,
173
      currentValue: undefined,
174
      state: 'miss'
175
    })
176
  }
177

178
  async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
179
    if (this.options.shouldSkipLayer(layer)) {
529✔
180
      return null
3✔
181
    }
182

183
    if (layer.getEntry) {
526✔
184
      try {
451✔
185
        return await layer.getEntry(key)
451✔
186
      } catch (error) {
187
        return this.options.handleLayerFailure(layer, 'read', error)
2✔
188
      }
189
    }
190

191
    try {
75✔
192
      return await layer.get(key)
75✔
193
    } catch (error) {
194
      return this.options.handleLayerFailure(layer, 'read', error)
3✔
195
    }
196
  }
197

198
  async backfill(key: string, stored: unknown, upToIndex: number, options?: CacheGetOptions): Promise<void> {
199
    if (upToIndex < 0) {
127✔
200
      return
107✔
201
    }
202

203
    const operations: Array<Promise<void>> = []
20✔
204

205
    for (let index = 0; index <= upToIndex; index += 1) {
20✔
206
      const layer = this.options.layers[index]
24✔
207
      if (!layer || this.options.shouldSkipLayer(layer)) {
24✔
208
        continue
4✔
209
      }
210

211
      const ttl =
20✔
212
        remainingStoredTtlMs(stored) ??
213
        this.options.resolveLayerMs(layer.name, options?.ttl, undefined, layer.defaultTtl)
214
      operations.push(
24✔
215
        (async () => {
216
          try {
20✔
217
            await layer.set(key, stored, ttl)
20✔
218
          } catch (error) {
219
            await this.options.handleLayerFailure(layer, 'backfill', error)
1✔
220
            return
1✔
221
          }
222
          this.options.metricsCollector.increment('backfills')
19✔
223
          this.options.logger.debug?.('backfill', { key, layer: layer.name })
19✔
224
          this.options.emit('backfill', { key, layer: layer.name })
20✔
225
        })()
226
      )
227
    }
228

229
    await Promise.all(operations)
24✔
230
  }
231

232
  abortAllRefreshes(): void {
233
    for (const key of this.backgroundRefreshAbort.keys()) {
29✔
234
      this.backgroundRefreshAbort.set(key, true)
1✔
235
    }
236
  }
237

238
  getAllRefreshPromises(): Promise<void>[] {
239
    return [...this.backgroundRefreshes.values()]
31✔
240
  }
241

242
  private async readFromLayers<T>(
243
    key: string,
244
    options: CacheGetOptions | undefined,
245
    mode: ReadMode
246
  ): Promise<ReadHit<T>> {
247
    let sawRetainableValue = false
462✔
248

249
    for (let index = 0; index < this.options.layers.length; index += 1) {
462✔
250
      const layer = this.options.layers[index]
484✔
251
      if (!layer) continue
484!
252
      const readStart = performance.now()
484✔
253
      const stored = await this.readLayerEntry(layer, key)
484✔
254
      const readDuration = performance.now() - readStart
484✔
255
      this.options.metricsCollector.recordLatency(layer.name, readDuration)
484✔
256
      if (stored === null) {
484✔
257
        this.options.metricsCollector.incrementLayer('missesByLayer', layer.name)
351✔
258
        continue
351✔
259
      }
260

261
      const resolved = resolveStoredValue<T>(stored)
133✔
262
      if (resolved.state === 'expired') {
133✔
263
        await layer.delete(key)
2✔
264
        continue
2✔
265
      }
266

267
      sawRetainableValue = true
131✔
268

269
      if (mode === 'fresh-only' && resolved.state !== 'fresh') {
131✔
270
        continue
20✔
271
      }
272

273
      await this.options.tagIndex.touch(key)
111✔
274
      await this.backfill(key, stored, index - 1, options)
111✔
275
      this.options.metricsCollector.incrementLayer('hitsByLayer', layer.name)
111✔
276
      this.options.logger.debug?.('hit', { key, layer: layer.name, state: resolved.state })
111✔
277
      this.options.emit('hit', {
484✔
278
        key,
279
        layer: layer.name,
280
        state: resolved.state as CacheStackEvents['hit']['state']
281
      })
282
      return {
484✔
283
        found: true,
284
        value: resolved.value,
285
        stored,
286
        state: resolved.state,
287
        layerIndex: index,
288
        layerName: layer.name
289
      }
290
    }
291

292
    if (!sawRetainableValue) {
351✔
293
      await this.options.tagIndex.remove(key)
332✔
294
    }
295

296
    this.options.logger.debug?.('miss', { key, mode })
351✔
297
    this.options.emit('miss', { key, mode })
462✔
298
    return { found: false, value: null, stored: null, state: 'miss' }
462✔
299
  }
300

301
  private async fetchWithGuards<T>(
302
    key: string,
303
    fetcher: CacheFetcher<T>,
304
    options?: CacheGetOptions,
305
    expectedClearEpoch?: number,
306
    expectedKeyEpoch?: number,
307
    initialMissConfirmed = false,
187✔
308
    fetcherContext: CacheFetcherContext<T> = {
187✔
309
      key,
310
      currentValue: undefined,
311
      state: 'miss'
312
    }
313
  ): Promise<T | null> {
314
    const fetchTask = async (): Promise<T | null> => {
187✔
315
      const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator)
107✔
316
      if (shouldRecheckFreshLayers) {
107✔
317
        const secondHit = await this.readFromLayers<T>(key, options, 'fresh-only')
101✔
318
        if (secondHit.found) {
101✔
319
          this.options.metricsCollector.increment('hits')
2✔
320
          return secondHit.value
2✔
321
        }
322
      }
323

324
      return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext)
105✔
325
    }
326

327
    const singleFlightTask = async (): Promise<T | null> => {
187✔
328
      if (!this.options.singleFlightCoordinator) {
113✔
329
        return fetchTask()
101✔
330
      }
331

332
      try {
12✔
333
        return await this.options.singleFlightCoordinator.execute(
12✔
334
          key,
335
          this.resolveSingleFlightOptions(),
336
          fetchTask,
337
          () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext)
5✔
338
        )
339
      } catch (error) {
340
        if (!this.options.isGracefulDegradationEnabled()) {
3✔
341
          throw error
1✔
342
        }
343

344
        this.options.metricsCollector.increment('degradedOperations')
2✔
345
        this.options.logger.warn?.('single-flight-coordinator-degraded', {
2✔
346
          key,
347
          error: this.options.formatError(error)
348
        })
349
        this.options.emitError('single-flight', {
3✔
350
          key,
351
          degraded: true,
352
          error: this.options.formatError(error)
353
        })
354
        return fetchTask()
3✔
355
      }
356
    }
357

358
    if (this.options.stampedePrevention === false) {
187✔
359
      return singleFlightTask()
3✔
360
    }
361

362
    return this.options.stampedeGuard.execute(key, singleFlightTask)
184✔
363
  }
364

365
  private async waitForFreshValue<T>(
366
    key: string,
367
    fetcher: CacheFetcher<T>,
368
    options?: CacheGetOptions,
369
    expectedClearEpoch?: number,
370
    expectedKeyEpoch?: number,
371
    fetcherContext: CacheFetcherContext<T> = {
5✔
372
      key,
373
      currentValue: undefined,
374
      state: 'miss'
375
    }
376
  ): Promise<T | null> {
377
    const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS
5✔
378
    const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
5✔
379
    const deadline = Date.now() + timeoutMs
5✔
380
    let nextPollMs = pollIntervalMs
5✔
381

382
    this.options.metricsCollector.increment('singleFlightWaits')
5✔
383
    this.options.emit('stampede-dedupe', { key })
5✔
384

385
    while (Date.now() < deadline) {
5✔
386
      const hit = await this.readFromLayers<T>(key, options, 'fresh-only')
15✔
387
      if (hit.found) {
15✔
388
        this.options.metricsCollector.increment('hits')
2✔
389
        return hit.value
2✔
390
      }
391
      const remainingMs = deadline - Date.now()
13✔
392
      if (remainingMs <= 0) {
13!
NEW
393
        break
×
394
      }
395
      const delayMs = Math.min(this.jitterSingleFlightPoll(nextPollMs), remainingMs)
13✔
396
      await this.options.sleep(delayMs)
13✔
397
      nextPollMs = Math.min(nextPollMs * SINGLE_FLIGHT_BACKOFF_FACTOR, SINGLE_FLIGHT_MAX_POLL_MS, timeoutMs)
13✔
398
    }
399

400
    if (!this.options.singleFlightCoordinator) {
3!
401
      return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext)
×
402
    }
403

404
    return this.options.singleFlightCoordinator.execute(
3✔
405
      key,
406
      this.resolveSingleFlightOptions(),
407
      () => this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext),
3✔
408
      () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext)
×
409
    )
410
  }
411

412
  private jitterSingleFlightPoll(delayMs: number): number {
413
    const jitterRange = delayMs * SINGLE_FLIGHT_BACKOFF_JITTER
13✔
414
    return Math.max(1, Math.round(delayMs - jitterRange + Math.random() * jitterRange * 2))
13✔
415
  }
416

417
  private async fetchAndPopulate<T>(
418
    key: string,
419
    fetcher: CacheFetcher<T>,
420
    options?: CacheGetOptions,
421
    expectedClearEpoch?: number,
422
    expectedKeyEpoch?: number,
423
    fetcherContext: CacheFetcherContext<T> = {
108✔
424
      key,
425
      currentValue: undefined,
426
      state: 'miss'
427
    }
428
  ): Promise<T | null> {
429
    const circuitBreakerOptions = options?.circuitBreaker ?? this.options.circuitBreaker
108✔
430
    const breakerKey = this.resolveCircuitBreakerKey(key, circuitBreakerOptions)
108✔
431
    this.options.circuitBreakerManager.assertClosed(breakerKey, circuitBreakerOptions)
108✔
432
    this.options.metricsCollector.increment('fetches')
108✔
433
    const fetchStart = Date.now()
108✔
434
    let fetched: T
435

436
    try {
108✔
437
      fetched = await this.options.fetchRateLimiter.schedule(
108✔
438
        options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
206✔
439
        { key, fetcher },
440
        () => fetcher(fetcherContext)
104✔
441
      )
442
      this.options.circuitBreakerManager.recordSuccess(breakerKey)
85✔
443
      this.options.logger.debug?.('fetch', { key, durationMs: Date.now() - fetchStart })
85✔
444
    } catch (error) {
445
      this.options.recordCircuitFailure(key, breakerKey, circuitBreakerOptions, error)
16✔
446
      throw error
16✔
447
    }
448

449
    if (fetched === undefined || (fetched === null && !this.shouldCacheNullValues(options))) {
85✔
450
      if (!this.shouldNegativeCache(options)) {
9✔
451
        return null
4✔
452
      }
453

454
      if (this.options.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
5✔
455
        this.options.logger.debug?.('skip-negative-store-after-invalidation', {
1✔
456
          key,
457
          expectedClearEpoch,
458
          clearEpoch: this.options.maintenance.currentClearEpoch(),
459
          expectedKeyEpoch,
460
          keyEpoch: this.options.maintenance.currentKeyEpoch(key)
461
        })
462
        return null
1✔
463
      }
464

465
      await this.options.storeEntry(key, 'empty', null, options)
4✔
466
      return null
4✔
467
    }
468

469
    // Conditional caching: skip storage if shouldCache returns false
470
    if (options?.shouldCache) {
76✔
471
      try {
4✔
472
        if (!options.shouldCache(fetched)) {
4✔
473
          return fetched
2✔
474
        }
475
      } catch (error) {
476
        this.options.logger.warn?.('shouldCache-error', {
2✔
477
          key,
478
          error: this.options.formatError(error)
479
        })
480
      }
481
    }
482

483
    if (this.options.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
74✔
484
      this.options.logger.debug?.('skip-store-after-invalidation', {
2✔
485
        key,
486
        expectedClearEpoch,
487
        clearEpoch: this.options.maintenance.currentClearEpoch(),
488
        expectedKeyEpoch,
489
        keyEpoch: this.options.maintenance.currentKeyEpoch(key)
490
      })
491
      return fetched
2✔
492
    }
493

494
    await this.options.storeEntry(key, 'value', fetched, options)
72✔
495
    return fetched
67✔
496
  }
497

498
  private resolveCircuitBreakerKey(key: string, options: CacheCircuitBreakerOptions | undefined): string {
499
    if (!options) {
108✔
500
      return `key:${key}`
95✔
501
    }
502

503
    if (options.breakerKey) {
13✔
504
      return `custom:${options.breakerKey}`
3✔
505
    }
506

507
    if (options.scope === 'shared') {
10✔
508
      return 'scope:shared'
1✔
509
    }
510

511
    return `key:${key}`
9✔
512
  }
513

514
  runScheduleBackgroundRefresh<T>(
515
    key: string,
516
    fetcher: CacheFetcher<T>,
517
    options?: CacheGetOptions,
518
    fetcherContext?: CacheFetcherContext<T>
519
  ): void {
520
    this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext)
5✔
521
  }
522

523
  private scheduleBackgroundRefresh<T>(
524
    key: string,
525
    fetcher: CacheFetcher<T>,
526
    options?: CacheGetOptions,
527
    fetcherContext: CacheFetcherContext<T> = {
20✔
528
      key,
529
      currentValue: undefined,
530
      state: 'miss'
531
    }
532
  ): void {
533
    if (
20✔
534
      !shouldStartBackgroundRefresh({
535
        isDisconnecting: this.options.isDisconnecting(),
536
        hasRefreshInFlight: this.backgroundRefreshes.has(key)
537
      })
538
    ) {
539
      return
2✔
540
    }
541

542
    const clearEpoch = this.options.maintenance.currentClearEpoch()
18✔
543
    const keyEpoch = this.options.maintenance.currentKeyEpoch(key)
18✔
544
    this.backgroundRefreshAbort.set(key, false)
18✔
545
    const refresh = (async () => {
18✔
546
      this.options.metricsCollector.increment('refreshes')
18✔
547
      try {
18✔
548
        if (this.backgroundRefreshAbort.get(key)) return
18!
549
        await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch, fetcherContext)
18✔
550
      } catch (error) {
551
        if (this.backgroundRefreshAbort.get(key)) return
4!
552
        this.options.metricsCollector.increment('refreshErrors')
4✔
553
        this.options.logger.warn?.('background-refresh-error', {
4✔
554
          key,
555
          error: this.options.formatError(error)
556
        })
557
      } finally {
558
        this.backgroundRefreshes.delete(key)
16✔
559
        this.backgroundRefreshAbort.delete(key)
16✔
560
      }
561
    })()
562

563
    this.backgroundRefreshes.set(key, refresh)
18✔
564
  }
565

566
  private async runBackgroundRefresh<T>(
567
    key: string,
568
    fetcher: CacheFetcher<T>,
569
    options?: CacheGetOptions,
570
    expectedClearEpoch?: number,
571
    expectedKeyEpoch?: number,
572
    fetcherContext: CacheFetcherContext<T> = {
18✔
573
      key,
574
      currentValue: undefined,
575
      state: 'miss'
576
    }
577
  ): Promise<void> {
578
    const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS
18✔
579
    await this.fetchWithGuards(
18✔
580
      key,
581
      (context) =>
582
        this.options.withTimeout(fetcher(context), timeoutMs, () => {
18✔
583
          return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`)
4✔
584
        }),
585
      options,
586
      expectedClearEpoch,
587
      expectedKeyEpoch,
588
      false,
589
      fetcherContext
590
    )
591
  }
592

593
  async runApplyFreshReadPolicies<T>(
594
    key: string,
595
    hit: {
596
      found: true
597
      value: T | null
598
      stored: unknown
599
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
600
      layerIndex: number
601
      layerName: string
602
    },
603
    options: CacheGetOptions | undefined,
604
    fetcher?: CacheFetcher<T>
605
  ): Promise<void> {
606
    return this.applyFreshReadPolicies(key, hit as Extract<ReadHit<T>, { found: true }>, options, fetcher)
4✔
607
  }
608

609
  private async applyFreshReadPolicies<T>(
610
    key: string,
611
    hit: Extract<ReadHit<T>, { found: true }>,
612
    options: CacheGetOptions | undefined,
613
    fetcher?: CacheFetcher<T>
614
  ): Promise<void> {
615
    const plan = planFreshReadPolicies({
89✔
616
      stored: hit.stored,
617
      hasFetcher: Boolean(fetcher),
618
      slidingTtl: options?.slidingTtl ?? false,
171✔
619
      refreshAheadMs:
620
        this.options.resolveLayerMs(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0
97✔
621
    })
622

623
    if (plan.refreshedStored) {
89✔
624
      for (let index = 0; index <= hit.layerIndex; index += 1) {
7✔
625
        const layer = this.options.layers[index]
10✔
626
        if (!layer || this.options.shouldSkipLayer(layer)) {
10✔
627
          continue
1✔
628
        }
629

630
        try {
9✔
631
          await layer.set(key, plan.refreshedStored, plan.refreshedStoredTtl)
9✔
632
        } catch (error) {
633
          await this.options.handleLayerFailure(layer, 'sliding-ttl', error)
1✔
634
        }
635
      }
636
    }
637

638
    if (fetcher && plan.shouldScheduleBackgroundRefresh) {
89✔
639
      this.options.scheduleBackgroundRefreshDispatch(key, fetcher, options, this.createFetcherContext(key, hit))
2✔
640
    }
641
  }
642

643
  private createFetcherContext<T>(key: string, hit: Extract<ReadHit<T>, { found: true }>): CacheFetcherContext<T> {
644
    return {
21✔
645
      key,
646
      currentValue: hit.value === null ? undefined : hit.value,
21!
647
      state: hit.state,
648
      layer: hit.layerName
649
    }
650
  }
651

652
  private resolveSingleFlightOptions(): CacheSingleFlightExecutionOptions {
653
    return {
15✔
654
      leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
29✔
655
      waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
22✔
656
      pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
22✔
657
      renewIntervalMs: this.options.singleFlightRenewIntervalMs
658
    }
659
  }
660

661
  private shouldNegativeCache(options?: CacheGetOptions): boolean {
662
    return options?.negativeCache ?? this.options.negativeCaching ?? false
9✔
663
  }
664

665
  private shouldCacheNullValues(options?: CacheGetOptions): boolean {
666
    return options?.cacheNullValues ?? this.options.cacheNullValues ?? false
10✔
667
  }
668

669
  private isNegativeStoredValue(stored: unknown): boolean {
670
    return isStoredValueEnvelope(stored) && stored.kind === 'empty'
107✔
671
  }
672
}
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