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

flyingsquirrel0419 / layercache / 29149114635

11 Jul 2026 10:17AM UTC coverage: 95.423% (-0.5%) from 95.882%
29149114635

Pull #101

github

web-flow
Merge 3a92bb146 into e5107fec2
Pull Request #101: Fix cache security hardening

1951 of 2112 branches covered (92.38%)

Branch coverage included in aggregate %.

287 of 307 new or added lines in 23 files covered. (93.49%)

5 existing lines in 2 files now uncovered.

3428 of 3525 relevant lines covered (97.25%)

302.46 hits per line

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

95.1
/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 | null
41
      stored: unknown
42
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
43
      layerIndex: number
44
      layerName: string
45
    }
46
  | { found: false; value: null; 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>>()
318✔
113
  private readonly backgroundRefreshAbort = new Map<string, boolean>()
318✔
114

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

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

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

133
      if (hit.state === 'fresh') {
106✔
134
        this.options.metricsCollector.increment('hits')
84✔
135
        await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher)
84✔
136
        return hit.value
84✔
137
      }
138

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

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

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

177
    this.options.metricsCollector.increment('misses')
248✔
178
    if (!fetcher) {
248✔
179
      return null
75✔
180
    }
181

182
    return this.fetchWithGuards(
173✔
183
      normalizedKey,
184
      fetcher,
185
      options,
186
      operationFence.clearEpoch,
187
      operationFence.keyEpoch,
188
      true,
189
      {
190
        key: normalizedKey,
191
        currentValue: undefined,
192
        state: 'miss'
193
      }
194
    )
195
  }
196

197
  async readLayerEntry(layer: CacheLayer, key: string): Promise<unknown | null> {
198
    if (this.options.shouldSkipLayer(layer)) {
546✔
199
      return null
3✔
200
    }
201

202
    if (layer.getEntry) {
543✔
203
      try {
462✔
204
        return await layer.getEntry(key)
462✔
205
      } catch (error) {
206
        return this.options.handleLayerFailure(layer, 'read', error)
2✔
207
      }
208
    }
209

210
    try {
81✔
211
      return await layer.get(key)
81✔
212
    } catch (error) {
213
      return this.options.handleLayerFailure(layer, 'read', error)
3✔
214
    }
215
  }
216

217
  async backfill(
218
    key: string,
219
    stored: unknown,
220
    upToIndex: number,
221
    options?: CacheGetOptions,
222
    fence: CacheWriteFence = {
130✔
223
      clearEpoch: this.options.maintenance.currentClearEpoch(),
224
      keyEpoch: this.options.maintenance.currentKeyEpoch(key)
225
    }
226
  ): Promise<void> {
227
    if (upToIndex < 0) {
130✔
228
      return
109✔
229
    }
230

231
    const operations: Array<() => Promise<void>> = []
21✔
232

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

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

255
    const layers = this.options.layers.slice(0, upToIndex + 1).filter((layer) => Boolean(layer))
25✔
256
    await this.options.maintenance.runFencedWrite(
25✔
257
      key,
258
      fence.clearEpoch,
259
      fence.keyEpoch,
260
      () => Promise.all(operations.map((operation) => operation())).then(() => undefined),
21✔
NEW
261
      () => Promise.all(layers.map((layer) => layer.delete(key))).then(() => undefined)
×
262
    )
263
  }
264

265
  abortAllRefreshes(): void {
266
    for (const key of this.backgroundRefreshAbort.keys()) {
29✔
267
      this.backgroundRefreshAbort.set(key, true)
1✔
268
    }
269
  }
270

271
  getAllRefreshPromises(): Promise<void>[] {
272
    return [...this.backgroundRefreshes.values()]
31✔
273
  }
274

275
  private async readFromLayers<T>(
276
    key: string,
277
    options: CacheGetOptions | undefined,
278
    mode: ReadMode
279
  ): Promise<ReadHit<T>> {
280
    const readFence = {
477✔
281
      clearEpoch: this.options.maintenance.currentClearEpoch(),
282
      keyEpoch: this.options.maintenance.currentKeyEpoch(key)
283
    }
284
    let sawRetainableValue = false
477✔
285

286
    for (let index = 0; index < this.options.layers.length; index += 1) {
477✔
287
      const layer = this.options.layers[index]
499✔
288
      if (!layer) continue
499!
289
      const readStart = performance.now()
499✔
290
      const stored = await this.readLayerEntry(layer, key)
499✔
291
      const readDuration = performance.now() - readStart
499✔
292
      this.options.metricsCollector.recordLatency(layer.name, readDuration)
499✔
293
      if (stored === null) {
499✔
294
        this.options.metricsCollector.incrementLayer('missesByLayer', layer.name)
368✔
295
        continue
368✔
296
      }
297

298
      const resolved = resolveStoredValue<T>(stored)
131✔
299
      if (resolved.state === 'expired') {
131✔
300
        await layer.delete(key)
2✔
301
        continue
2✔
302
      }
303

304
      sawRetainableValue = true
129✔
305

306
      if (mode === 'fresh-only' && resolved.state !== 'fresh') {
129✔
307
        continue
19✔
308
      }
309

310
      await this.options.tagIndex.touch(key)
110✔
311
      await this.backfill(key, stored, index - 1, options, readFence)
110✔
312
      this.options.metricsCollector.incrementLayer('hitsByLayer', layer.name)
110✔
313
      this.options.logger.debug?.('hit', { key, layer: layer.name, state: resolved.state })
110✔
314
      this.options.emit('hit', {
499✔
315
        key,
316
        layer: layer.name,
317
        state: resolved.state as CacheStackEvents['hit']['state']
318
      })
319
      return {
499✔
320
        found: true,
321
        value: resolved.value,
322
        stored,
323
        state: resolved.state,
324
        layerIndex: index,
325
        layerName: layer.name
326
      }
327
    }
328

329
    if (!sawRetainableValue) {
367✔
330
      await this.options.tagIndex.remove(key)
349✔
331
    }
332

333
    this.options.logger.debug?.('miss', { key, mode })
367✔
334
    this.options.emit('miss', { key, mode })
477✔
335
    return { found: false, value: null, stored: null, state: 'miss' }
477✔
336
  }
337

338
  private async fetchWithGuards<T>(
339
    key: string,
340
    fetcher: CacheFetcher<T>,
341
    options?: CacheGetOptions,
342
    expectedClearEpoch?: number,
343
    expectedKeyEpoch?: number,
344
    initialMissConfirmed = false,
194✔
345
    fetcherContext: CacheFetcherContext<T> = {
194✔
346
      key,
347
      currentValue: undefined,
348
      state: 'miss'
349
    }
350
  ): Promise<T | null> {
351
    const clearEpoch = expectedClearEpoch ?? this.options.maintenance.currentClearEpoch()
194!
352
    const keyEpoch = expectedKeyEpoch ?? this.options.maintenance.currentKeyEpoch(key)
194!
353
    const fetchTask = async (): Promise<T | null> => {
194✔
354
      const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator)
114✔
355
      if (shouldRecheckFreshLayers) {
114✔
356
        const secondHit = await this.readFromLayers<T>(key, options, 'fresh-only')
108✔
357
        if (secondHit.found) {
108✔
358
          this.options.metricsCollector.increment('hits')
2✔
359
          return secondHit.value
2✔
360
        }
361
      }
362

363
      return this.fetchAndPopulate(key, fetcher, options, clearEpoch, keyEpoch, fetcherContext)
112✔
364
    }
365

366
    const singleFlightTask = async (): Promise<T | null> => {
194✔
367
      if (!this.options.singleFlightCoordinator) {
120✔
368
        return fetchTask()
108✔
369
      }
370

371
      try {
12✔
372
        return await this.options.singleFlightCoordinator.execute(
12✔
373
          key,
374
          this.resolveSingleFlightOptions(),
375
          fetchTask,
376
          () => this.waitForFreshValue(key, fetcher, options, clearEpoch, keyEpoch, fetcherContext)
5✔
377
        )
378
      } catch (error) {
379
        if (!this.options.isGracefulDegradationEnabled()) {
3✔
380
          throw error
1✔
381
        }
382

383
        this.options.metricsCollector.increment('degradedOperations')
2✔
384
        this.options.logger.warn?.('single-flight-coordinator-degraded', {
2✔
385
          key,
386
          error: this.options.formatError(error)
387
        })
388
        this.options.emitError('single-flight', {
3✔
389
          key,
390
          degraded: true,
391
          error: this.options.formatError(error)
392
        })
393
        return fetchTask()
3✔
394
      }
395
    }
396

397
    if (this.options.stampedePrevention === false) {
194✔
398
      return singleFlightTask()
3✔
399
    }
400

401
    return this.options.stampedeGuard.execute(key, singleFlightTask)
191✔
402
  }
403

404
  private async waitForFreshValue<T>(
405
    key: string,
406
    fetcher: CacheFetcher<T>,
407
    options?: CacheGetOptions,
408
    expectedClearEpoch?: number,
409
    expectedKeyEpoch?: number,
410
    fetcherContext: CacheFetcherContext<T> = {
5✔
411
      key,
412
      currentValue: undefined,
413
      state: 'miss'
414
    },
415
    deadline?: number,
416
    coordinatorRetries = 0
5✔
417
  ): Promise<T | null> {
418
    const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS
5✔
419
    const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
5✔
420
    const operationDeadline = deadline ?? Date.now() + timeoutMs
5✔
421
    let nextPollMs = pollIntervalMs
5✔
422

423
    this.options.metricsCollector.increment('singleFlightWaits')
5✔
424
    this.options.emit('stampede-dedupe', { key })
5✔
425

426
    while (Date.now() < operationDeadline) {
5✔
427
      const hit = await this.readFromLayers<T>(key, options, 'fresh-only')
15✔
428
      if (hit.found) {
15✔
429
        this.options.metricsCollector.increment('hits')
2✔
430
        return hit.value
2✔
431
      }
432
      const remainingMs = operationDeadline - Date.now()
13✔
433
      if (remainingMs <= 0) {
13!
434
        break
×
435
      }
436
      const delayMs = Math.min(this.jitterSingleFlightPoll(nextPollMs), remainingMs)
13✔
437
      await this.options.sleep(delayMs)
13✔
438
      nextPollMs = Math.min(nextPollMs * SINGLE_FLIGHT_BACKOFF_FACTOR, SINGLE_FLIGHT_MAX_POLL_MS, timeoutMs)
13✔
439
    }
440

441
    if (!this.options.singleFlightCoordinator || coordinatorRetries >= 1) {
3!
NEW
442
      throw new Error(`Single-flight wait timed out after ${timeoutMs}ms for key "${key}".`)
×
443
    }
444

445
    return this.options.singleFlightCoordinator.execute(
3✔
446
      key,
447
      this.resolveSingleFlightOptions(),
448
      () => this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext),
3✔
449
      () =>
NEW
450
        this.waitForFreshValue(
×
451
          key,
452
          fetcher,
453
          options,
454
          expectedClearEpoch,
455
          expectedKeyEpoch,
456
          fetcherContext,
457
          operationDeadline,
458
          coordinatorRetries + 1
459
        )
460
    )
461
  }
462

463
  private jitterSingleFlightPoll(delayMs: number): number {
464
    const jitterRange = delayMs * SINGLE_FLIGHT_BACKOFF_JITTER
13✔
465
    return Math.max(1, Math.round(delayMs - jitterRange + Math.random() * jitterRange * 2))
13✔
466
  }
467

468
  private async fetchAndPopulate<T>(
469
    key: string,
470
    fetcher: CacheFetcher<T>,
471
    options?: CacheGetOptions,
472
    expectedClearEpoch?: number,
473
    expectedKeyEpoch?: number,
474
    fetcherContext: CacheFetcherContext<T> = {
115✔
475
      key,
476
      currentValue: undefined,
477
      state: 'miss'
478
    }
479
  ): Promise<T | null> {
480
    const circuitBreakerOptions = options?.circuitBreaker ?? this.options.circuitBreaker
115✔
481
    const breakerKey = this.resolveCircuitBreakerKey(key, circuitBreakerOptions)
115✔
482
    this.options.circuitBreakerManager.assertClosed(breakerKey, circuitBreakerOptions)
115✔
483
    this.options.metricsCollector.increment('fetches')
115✔
484
    const fetchStart = Date.now()
115✔
485
    let fetched: T
486

487
    try {
115✔
488
      fetched = await this.options.fetchRateLimiter.schedule(
115✔
489
        options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
220✔
490
        { key, fetcher },
491
        () => fetcher(fetcherContext)
111✔
492
      )
493
      this.options.circuitBreakerManager.recordSuccess(breakerKey)
93✔
494
      this.options.logger.debug?.('fetch', { key, durationMs: Date.now() - fetchStart })
93✔
495
    } catch (error) {
496
      if (!(error instanceof FetchRateLimitError)) {
13!
497
        this.options.recordCircuitFailure(key, breakerKey, circuitBreakerOptions, error)
13✔
498
      }
499
      throw error
13✔
500
    }
501

502
    if (fetched === undefined || (fetched === null && !this.shouldCacheNullValues(options))) {
93✔
503
      if (!this.shouldNegativeCache(options)) {
9✔
504
        return null
4✔
505
      }
506

507
      if (this.options.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
5✔
508
        this.options.logger.debug?.('skip-negative-store-after-invalidation', {
1✔
509
          key,
510
          expectedClearEpoch,
511
          clearEpoch: this.options.maintenance.currentClearEpoch(),
512
          expectedKeyEpoch,
513
          keyEpoch: this.options.maintenance.currentKeyEpoch(key)
514
        })
515
        return null
1✔
516
      }
517

518
      await this.options.storeEntry(key, 'empty', null, options, {
4✔
519
        clearEpoch: expectedClearEpoch ?? this.options.maintenance.currentClearEpoch(),
4!
520
        keyEpoch: expectedKeyEpoch ?? this.options.maintenance.currentKeyEpoch(key)
9!
521
      })
522
      return null
4✔
523
    }
524

525
    // Conditional caching: skip storage if shouldCache returns false
526
    if (options?.shouldCache) {
84✔
527
      try {
12✔
528
        if (!options.shouldCache(fetched)) {
12✔
529
          return fetched
4✔
530
        }
531
      } catch (error) {
532
        this.options.logger.warn?.('shouldCache-error', {
2✔
533
          key,
534
          error: this.options.formatError(error)
535
        })
536
        return fetched
2✔
537
      }
538
    }
539

540
    if (this.options.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
78✔
541
      this.options.logger.debug?.('skip-store-after-invalidation', {
3✔
542
        key,
543
        expectedClearEpoch,
544
        clearEpoch: this.options.maintenance.currentClearEpoch(),
545
        expectedKeyEpoch,
546
        keyEpoch: this.options.maintenance.currentKeyEpoch(key)
547
      })
548
      return fetched
3✔
549
    }
550

551
    await this.options.storeEntry(key, 'value', fetched, options, {
75✔
552
      clearEpoch: expectedClearEpoch ?? this.options.maintenance.currentClearEpoch(),
75!
553
      keyEpoch: expectedKeyEpoch ?? this.options.maintenance.currentKeyEpoch(key)
115!
554
    })
555
    return fetched
70✔
556
  }
557

558
  private resolveCircuitBreakerKey(key: string, options: CacheCircuitBreakerOptions | undefined): string {
559
    if (!options) {
115✔
560
      return `key:${key}`
102✔
561
    }
562

563
    if (options.breakerKey) {
13✔
564
      return `custom:${options.breakerKey}`
3✔
565
    }
566

567
    if (options.scope === 'shared') {
10✔
568
      return 'scope:shared'
1✔
569
    }
570

571
    return `key:${key}`
9✔
572
  }
573

574
  runScheduleBackgroundRefresh<T>(
575
    key: string,
576
    fetcher: CacheFetcher<T>,
577
    options?: CacheGetOptions,
578
    fetcherContext?: CacheFetcherContext<T>
579
  ): void {
580
    this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext)
5✔
581
  }
582

583
  private scheduleBackgroundRefresh<T>(
584
    key: string,
585
    fetcher: CacheFetcher<T>,
586
    options?: CacheGetOptions,
587
    fetcherContext: CacheFetcherContext<T> = {
20✔
588
      key,
589
      currentValue: undefined,
590
      state: 'miss'
591
    }
592
  ): void {
593
    if (
20✔
594
      !shouldStartBackgroundRefresh({
595
        isDisconnecting: this.options.isDisconnecting(),
596
        hasRefreshInFlight: this.backgroundRefreshes.has(key)
597
      })
598
    ) {
599
      return
3✔
600
    }
601

602
    const clearEpoch = this.options.maintenance.currentClearEpoch()
17✔
603
    const keyEpoch = this.options.maintenance.currentKeyEpoch(key)
17✔
604
    this.backgroundRefreshAbort.set(key, false)
17✔
605
    const refresh = (async () => {
17✔
606
      this.options.metricsCollector.increment('refreshes')
17✔
607
      try {
17✔
608
        if (this.backgroundRefreshAbort.get(key)) return
17!
609
        await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch, fetcherContext)
17✔
610
      } catch (error) {
611
        if (this.backgroundRefreshAbort.get(key)) return
1!
612
        this.options.metricsCollector.increment('refreshErrors')
1✔
613
        this.options.logger.warn?.('background-refresh-error', {
1✔
614
          key,
615
          error: this.options.formatError(error)
616
        })
617
      } finally {
618
        if (this.backgroundRefreshes.get(key) === refresh) {
13!
619
          this.backgroundRefreshes.delete(key)
13✔
620
          this.backgroundRefreshAbort.delete(key)
13✔
621
        }
622
      }
623
    })()
624

625
    this.backgroundRefreshes.set(key, refresh)
17✔
626
    const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS
17✔
627
    void this.options
20✔
628
      .withTimeout(refresh, timeoutMs, () => {
629
        return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`)
3✔
630
      })
631
      .catch((error) => {
632
        if (this.backgroundRefreshAbort.get(key)) return
3!
633
        this.options.metricsCollector.increment('refreshErrors')
3✔
634
        this.options.logger.warn?.('background-refresh-timeout', {
3✔
635
          key,
636
          error: this.options.formatError(error)
637
        })
638
      })
639
  }
640

641
  private async runBackgroundRefresh<T>(
642
    key: string,
643
    fetcher: CacheFetcher<T>,
644
    options?: CacheGetOptions,
645
    expectedClearEpoch?: number,
646
    expectedKeyEpoch?: number,
647
    fetcherContext: CacheFetcherContext<T> = {
17✔
648
      key,
649
      currentValue: undefined,
650
      state: 'miss'
651
    }
652
  ): Promise<void> {
653
    await this.fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, false, fetcherContext)
17✔
654
  }
655

656
  async runApplyFreshReadPolicies<T>(
657
    key: string,
658
    hit: {
659
      found: true
660
      value: T | null
661
      stored: unknown
662
      state: 'fresh' | 'stale-while-revalidate' | 'stale-if-error'
663
      layerIndex: number
664
      layerName: string
665
    },
666
    options: CacheGetOptions | undefined,
667
    fetcher?: CacheFetcher<T>
668
  ): Promise<void> {
669
    return this.applyFreshReadPolicies(key, hit as Extract<ReadHit<T>, { found: true }>, options, fetcher)
4✔
670
  }
671

672
  private async applyFreshReadPolicies<T>(
673
    key: string,
674
    hit: Extract<ReadHit<T>, { found: true }>,
675
    options: CacheGetOptions | undefined,
676
    fetcher?: CacheFetcher<T>
677
  ): Promise<void> {
678
    const plan = planFreshReadPolicies({
88✔
679
      stored: hit.stored,
680
      hasFetcher: Boolean(fetcher),
681
      slidingTtl: options?.slidingTtl ?? false,
169✔
682
      refreshAheadMs:
683
        this.options.resolveLayerMs(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0
96✔
684
    })
685

686
    if (plan.refreshedStored) {
88✔
687
      for (let index = 0; index <= hit.layerIndex; index += 1) {
7✔
688
        const layer = this.options.layers[index]
10✔
689
        if (!layer || this.options.shouldSkipLayer(layer)) {
10✔
690
          continue
1✔
691
        }
692

693
        try {
9✔
694
          await layer.set(key, plan.refreshedStored, plan.refreshedStoredTtl)
9✔
695
        } catch (error) {
696
          await this.options.handleLayerFailure(layer, 'sliding-ttl', error)
1✔
697
        }
698
      }
699
    }
700

701
    if (fetcher && plan.shouldScheduleBackgroundRefresh) {
88✔
702
      this.options.scheduleBackgroundRefreshDispatch(key, fetcher, options, this.createFetcherContext(key, hit))
2✔
703
    }
704
  }
705

706
  private createFetcherContext<T>(key: string, hit: Extract<ReadHit<T>, { found: true }>): CacheFetcherContext<T> {
707
    return {
21✔
708
      key,
709
      currentValue: hit.value === null ? undefined : hit.value,
21!
710
      state: hit.state,
711
      layer: hit.layerName
712
    }
713
  }
714

715
  private resolveSingleFlightOptions(): CacheSingleFlightExecutionOptions {
716
    return {
15✔
717
      leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
29✔
718
      waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
22✔
719
      pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
22✔
720
      renewIntervalMs: this.options.singleFlightRenewIntervalMs
721
    }
722
  }
723

724
  private shouldNegativeCache(options?: CacheGetOptions): boolean {
725
    return options?.negativeCache ?? this.options.negativeCaching ?? false
9✔
726
  }
727

728
  private shouldCacheNullValues(options?: CacheGetOptions): boolean {
729
    return options?.cacheNullValues ?? this.options.cacheNullValues ?? false
10✔
730
  }
731

732
  private isNegativeStoredValue(stored: unknown): boolean {
733
    return isStoredValueEnvelope(stored) && stored.kind === 'empty'
106✔
734
  }
735
}
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