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

flyingsquirrel0419 / layercache / 29436437414

15 Jul 2026 05:25PM UTC coverage: 95.8% (-0.08%) from 95.882%
29436437414

Pull #101

github

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

2009 of 2163 branches covered (92.88%)

Branch coverage included in aggregate %.

401 of 420 new or added lines in 25 files covered. (95.48%)

2 existing lines in 1 file now uncovered.

3534 of 3623 relevant lines covered (97.54%)

391.04 hits per line

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

95.43
/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>>()
322✔
113
  private readonly backgroundRefreshAbort = new Map<string, boolean>()
322✔
114

115
  constructor(private readonly options: CacheStackReaderOptions) {}
322✔
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 = {
357✔
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')
357✔
127
    if (hit.found) {
357✔
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')
251✔
178
    if (!fetcher) {
251✔
179
      return null
75✔
180
    }
181

182
    return this.fetchWithGuards(
176✔
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)) {
553✔
199
      return null
3✔
200
    }
201

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

210
    try {
84✔
211
      return await layer.get(key)
84✔
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
    const executeBackfills = async (): Promise<void> => {
25✔
257
      const pending: Array<Promise<void>> = []
21✔
258
      for (const operation of operations) pending.push(operation())
21✔
259
      await Promise.all(pending)
21✔
260
    }
261
    const cleanupBackfills = async (): Promise<void> => {
25✔
NEW
262
      const pending: Array<Promise<void>> = []
×
NEW
263
      for (const layer of layers) pending.push(layer.delete(key))
×
NEW
264
      await Promise.all(pending)
×
265
    }
266
    await this.options.maintenance.runFencedWrite(
25✔
267
      key,
268
      fence.clearEpoch,
269
      fence.keyEpoch,
270
      executeBackfills,
271
      cleanupBackfills
272
    )
273
  }
274

275
  abortAllRefreshes(): void {
276
    for (const key of this.backgroundRefreshAbort.keys()) {
29✔
277
      this.backgroundRefreshAbort.set(key, true)
1✔
278
    }
279
  }
280

281
  getAllRefreshPromises(): Promise<void>[] {
282
    return [...this.backgroundRefreshes.values()]
31✔
283
  }
284

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

296
    for (let index = 0; index < this.options.layers.length; index += 1) {
484✔
297
      const layer = this.options.layers[index]
506✔
298
      if (!layer) continue
506!
299
      const readStart = performance.now()
506✔
300
      const stored = await this.readLayerEntry(layer, key)
506✔
301
      const readDuration = performance.now() - readStart
506✔
302
      this.options.metricsCollector.recordLatency(layer.name, readDuration)
506✔
303
      if (stored === null) {
506✔
304
        this.options.metricsCollector.incrementLayer('missesByLayer', layer.name)
375✔
305
        continue
375✔
306
      }
307

308
      const resolved = resolveStoredValue<T>(stored)
131✔
309
      if (resolved.state === 'expired') {
131✔
310
        await layer.delete(key)
2✔
311
        continue
2✔
312
      }
313

314
      sawRetainableValue = true
129✔
315

316
      if (mode === 'fresh-only' && resolved.state !== 'fresh') {
129✔
317
        continue
19✔
318
      }
319

320
      await this.options.tagIndex.touch(key)
110✔
321
      await this.backfill(key, stored, index - 1, options, readFence)
110✔
322
      this.options.metricsCollector.incrementLayer('hitsByLayer', layer.name)
110✔
323
      this.options.logger.debug?.('hit', { key, layer: layer.name, state: resolved.state })
110✔
324
      this.options.emit('hit', {
506✔
325
        key,
326
        layer: layer.name,
327
        state: resolved.state as CacheStackEvents['hit']['state']
328
      })
329
      return {
506✔
330
        found: true,
331
        value: resolved.value,
332
        stored,
333
        state: resolved.state,
334
        layerIndex: index,
335
        layerName: layer.name
336
      }
337
    }
338

339
    if (!sawRetainableValue) {
374✔
340
      await this.options.tagIndex.remove(key)
356✔
341
    }
342

343
    this.options.logger.debug?.('miss', { key, mode })
374✔
344
    this.options.emit('miss', { key, mode })
484✔
345
    return { found: false, value: null, stored: null, state: 'miss' }
484✔
346
  }
347

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

373
      return this.fetchAndPopulate(key, fetcher, options, clearEpoch, keyEpoch, fetcherContext)
114✔
374
    }
375

376
    const singleFlightTask = async (): Promise<T | null> => {
197✔
377
      if (!this.options.singleFlightCoordinator) {
123✔
378
        return fetchTask()
110✔
379
      }
380

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

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

407
    if (this.options.stampedePrevention === false) {
197✔
408
      return singleFlightTask()
3✔
409
    }
410

411
    return this.options.stampedeGuard.execute(key, singleFlightTask)
194✔
412
  }
413

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

433
    this.options.metricsCollector.increment('singleFlightWaits')
7✔
434
    this.options.emit('stampede-dedupe', { key })
7✔
435

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

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

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

473
  private jitterSingleFlightPoll(delayMs: number): number {
474
    const jitterRange = delayMs * SINGLE_FLIGHT_BACKOFF_JITTER
15✔
475
    return Math.max(1, Math.round(delayMs - jitterRange + Math.random() * jitterRange * 2))
15✔
476
  }
477

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

497
    try {
117✔
498
      fetched = await this.options.fetchRateLimiter.schedule(
117✔
499
        options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
224✔
500
        { key, fetcher },
501
        () => fetcher(fetcherContext)
113✔
502
      )
503
      this.options.circuitBreakerManager.recordSuccess(breakerKey)
95✔
504
      this.options.logger.debug?.('fetch', { key, durationMs: Date.now() - fetchStart })
95✔
505
    } catch (error) {
506
      if (!(error instanceof FetchRateLimitError)) {
13!
507
        this.options.recordCircuitFailure(key, breakerKey, circuitBreakerOptions, error)
13✔
508
      }
509
      throw error
13✔
510
    }
511

512
    if (fetched === undefined || (fetched === null && !this.shouldCacheNullValues(options))) {
95✔
513
      if (!this.shouldNegativeCache(options)) {
9✔
514
        return null
4✔
515
      }
516

517
      if (this.options.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
5✔
518
        this.options.logger.debug?.('skip-negative-store-after-invalidation', {
1✔
519
          key,
520
          expectedClearEpoch,
521
          clearEpoch: this.options.maintenance.currentClearEpoch(),
522
          expectedKeyEpoch,
523
          keyEpoch: this.options.maintenance.currentKeyEpoch(key)
524
        })
525
        return null
1✔
526
      }
527

528
      await this.options.storeEntry(key, 'empty', null, options, {
4✔
529
        clearEpoch: expectedClearEpoch ?? this.options.maintenance.currentClearEpoch(),
4!
530
        keyEpoch: expectedKeyEpoch ?? this.options.maintenance.currentKeyEpoch(key)
9!
531
      })
532
      return null
4✔
533
    }
534

535
    // Conditional caching: skip storage if shouldCache returns false
536
    if (options?.shouldCache) {
86✔
537
      try {
13✔
538
        if (!options.shouldCache(fetched)) {
13✔
539
          return fetched
4✔
540
        }
541
      } catch (error) {
542
        this.options.logger.warn?.('shouldCache-error', {
2✔
543
          key,
544
          error: this.options.formatError(error)
545
        })
546
        return fetched
2✔
547
      }
548
    }
549

550
    if (this.options.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
80✔
551
      this.options.logger.debug?.('skip-store-after-invalidation', {
3✔
552
        key,
553
        expectedClearEpoch,
554
        clearEpoch: this.options.maintenance.currentClearEpoch(),
555
        expectedKeyEpoch,
556
        keyEpoch: this.options.maintenance.currentKeyEpoch(key)
557
      })
558
      return fetched
3✔
559
    }
560

561
    await this.options.storeEntry(key, 'value', fetched, options, {
77✔
562
      clearEpoch: expectedClearEpoch ?? this.options.maintenance.currentClearEpoch(),
77!
563
      keyEpoch: expectedKeyEpoch ?? this.options.maintenance.currentKeyEpoch(key)
117!
564
    })
565
    return fetched
72✔
566
  }
567

568
  private resolveCircuitBreakerKey(key: string, options: CacheCircuitBreakerOptions | undefined): string {
569
    if (!options) {
117✔
570
      return `key:${key}`
104✔
571
    }
572

573
    if (options.breakerKey) {
13✔
574
      return `custom:${options.breakerKey}`
3✔
575
    }
576

577
    if (options.scope === 'shared') {
10✔
578
      return 'scope:shared'
1✔
579
    }
580

581
    return `key:${key}`
9✔
582
  }
583

584
  runScheduleBackgroundRefresh<T>(
585
    key: string,
586
    fetcher: CacheFetcher<T>,
587
    options?: CacheGetOptions,
588
    fetcherContext?: CacheFetcherContext<T>
589
  ): void {
590
    this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext)
5✔
591
  }
592

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

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

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

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

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

682
  private async applyFreshReadPolicies<T>(
683
    key: string,
684
    hit: Extract<ReadHit<T>, { found: true }>,
685
    options: CacheGetOptions | undefined,
686
    fetcher?: CacheFetcher<T>
687
  ): Promise<void> {
688
    const plan = planFreshReadPolicies({
88✔
689
      stored: hit.stored,
690
      hasFetcher: Boolean(fetcher),
691
      slidingTtl: options?.slidingTtl ?? false,
169✔
692
      refreshAheadMs:
693
        this.options.resolveLayerMs(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0
96✔
694
    })
695

696
    if (plan.refreshedStored) {
88✔
697
      for (let index = 0; index <= hit.layerIndex; index += 1) {
7✔
698
        const layer = this.options.layers[index]
10✔
699
        if (!layer || this.options.shouldSkipLayer(layer)) {
10✔
700
          continue
1✔
701
        }
702

703
        try {
9✔
704
          await layer.set(key, plan.refreshedStored, plan.refreshedStoredTtl)
9✔
705
        } catch (error) {
706
          await this.options.handleLayerFailure(layer, 'sliding-ttl', error)
1✔
707
        }
708
      }
709
    }
710

711
    if (fetcher && plan.shouldScheduleBackgroundRefresh) {
88✔
712
      this.options.scheduleBackgroundRefreshDispatch(key, fetcher, options, this.createFetcherContext(key, hit))
2✔
713
    }
714
  }
715

716
  private createFetcherContext<T>(key: string, hit: Extract<ReadHit<T>, { found: true }>): CacheFetcherContext<T> {
717
    return {
21✔
718
      key,
719
      currentValue: hit.value === null ? undefined : hit.value,
21!
720
      state: hit.state,
721
      layer: hit.layerName
722
    }
723
  }
724

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

734
  private shouldNegativeCache(options?: CacheGetOptions): boolean {
735
    return options?.negativeCache ?? this.options.negativeCaching ?? false
9✔
736
  }
737

738
  private shouldCacheNullValues(options?: CacheGetOptions): boolean {
739
    return options?.cacheNullValues ?? this.options.cacheNullValues ?? false
10✔
740
  }
741

742
  private isNegativeStoredValue(stored: unknown): boolean {
743
    return isStoredValueEnvelope(stored) && stored.kind === 'empty'
106✔
744
  }
745
}
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