@certrev/cert-block 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,12 @@
18
18
  * down costs the page nothing it already had. A definitive answer (a revocation, say)
19
19
  * replaces the entry the moment the refresh lands. With the default `staleMs` of 0 an
20
20
  * expired entry is gone, exactly as before 1.2.0.
21
+ * 5. (2.0.2, opt-in through `failureTtlMs`) FAILURE MEMORY: a transient result (`isTransient`: the origin was not
22
+ * reached) is remembered in a slot of its own for `failureTtlMs`, beside a positive entry and never over it. While
23
+ * it is remembered nothing loads for the key: a read with no servable entry gets the failure at once, and a stale
24
+ * entry is served without starting a refresh. A definitive answer clears it. So an ongoing outage costs a caller at
25
+ * most one load per key per failure window. `markFailed` lets a caller that stopped waiting (a render deadline)
26
+ * remember one before the load itself settles. Without `failureTtlMs` the cache behaves exactly as 2.0.1's.
21
27
  *
22
28
  * It is deliberately tiny + dependency-free (a Map + timestamps) so it runs in any server
23
29
  * runtime (Node, edge, workerd). For multi-instance deployments this is per-instance L1 and
@@ -44,6 +50,11 @@ export interface TtlCacheOptions {
44
50
  * it and none would ever refresh again.
45
51
  */
46
52
  readonly inflightMaxMs?: number
53
+ /**
54
+ * 2.0.2: how long a transient result (`isTransient`) is remembered, in a slot of its own (default: none, and a transient
55
+ * result is cached as a negative entry as before). See the module header, point 5.
56
+ */
57
+ readonly failureTtlMs?: number
47
58
  /** Injectable clock for deterministic tests. */
48
59
  readonly now?: () => number
49
60
  }
@@ -57,16 +68,19 @@ interface Entry<V> {
57
68
  interface Inflight<V> {
58
69
  readonly promise: Promise<V>
59
70
  readonly startedAt: number
71
+ readonly token: object
60
72
  }
61
73
 
62
74
  export class TtlCache<V> {
63
75
  private readonly store = new Map<string, Entry<V>>()
64
76
  private readonly inflight = new Map<string, Inflight<V>>()
77
+ private readonly failures = new Map<string, { readonly value: V; readonly until: number }>()
65
78
  private readonly ttlMs: number
66
79
  private readonly negativeTtlMs: number
67
80
  private readonly maxEntries: number
68
81
  private readonly staleMs: number
69
82
  private readonly inflightMaxMs: number
83
+ private readonly failureTtlMs: number
70
84
  private readonly now: () => number
71
85
 
72
86
  constructor(opts: TtlCacheOptions = {}) {
@@ -75,6 +89,7 @@ export class TtlCache<V> {
75
89
  this.maxEntries = opts.maxEntries ?? 1000
76
90
  this.staleMs = opts.staleMs !== undefined && opts.staleMs > 0 ? opts.staleMs : 0
77
91
  this.inflightMaxMs = opts.inflightMaxMs ?? Number.POSITIVE_INFINITY
92
+ this.failureTtlMs = opts.failureTtlMs !== undefined && opts.failureTtlMs > 0 ? opts.failureTtlMs : 0
78
93
  this.now = opts.now ?? Date.now
79
94
  }
80
95
 
@@ -89,10 +104,20 @@ export class TtlCache<V> {
89
104
  return undefined
90
105
  }
91
106
 
107
+ /** 2.0.2: the remembered failure while it lasts (only with `failureTtlMs`); prunes it past that. */
108
+ private failure(key: string): { readonly value: V } | undefined {
109
+ const f = this.failures.get(key)
110
+ if (!f) return undefined
111
+ if (f.until > this.now()) return f
112
+ this.failures.delete(key)
113
+ return undefined
114
+ }
115
+
92
116
  /** Read a live (non-expired) entry, or undefined. Never serves a stale one; prunes an entry past its stale window. */
93
117
  peek(key: string): V | undefined {
94
118
  const e = this.servable(key)
95
- return e && e.expiresAt > this.now() ? e.value : undefined
119
+ if (e && e.expiresAt > this.now()) return e.value
120
+ return this.failure(key)?.value
96
121
  }
97
122
 
98
123
  /**
@@ -109,8 +134,10 @@ export class TtlCache<V> {
109
134
  isTransient?: (v: V) => boolean,
110
135
  ): V | undefined {
111
136
  const e = this.servable(key)
112
- if (!e) return undefined
113
- if (e.expiresAt <= this.now()) {
137
+ const failed = this.failure(key)
138
+ if (!e) return failed?.value
139
+ // 2.0.2: no refresh while a failure is remembered; the stale value is still served.
140
+ if (e.expiresAt <= this.now() && !failed) {
114
141
  this.load(key, loader, isNegative, positiveTtlMs, isTransient).catch(() => undefined)
115
142
  }
116
143
  return e.value
@@ -158,9 +185,20 @@ export class TtlCache<V> {
158
185
  const existing = this.inflight.get(key)
159
186
  if (existing && this.now() - existing.startedAt < this.inflightMaxMs) return existing.promise
160
187
 
188
+ const token = {}
161
189
  const promise: Promise<V> = (async () => {
162
190
  const value = await loader()
163
- if (isTransient?.(value)) {
191
+ // 2.0.2: a load that was replaced (abandoned past `inflightMaxMs`) never writes: its answer may be older than
192
+ // the replacement's, and a late verified read must never overwrite a newer revocation.
193
+ if (this.inflight.get(key)?.token !== token) return value
194
+ if (this.failureTtlMs > 0) {
195
+ if (isTransient?.(value)) {
196
+ this.markFailed(key, value)
197
+ const kept = this.servable(key)
198
+ return kept && !kept.negative ? kept.value : value
199
+ }
200
+ this.failures.delete(key)
201
+ } else if (isTransient?.(value)) {
164
202
  const kept = this.servable(key)
165
203
  if (kept && !kept.negative) return kept.value
166
204
  }
@@ -169,10 +207,10 @@ export class TtlCache<V> {
169
207
  return value
170
208
  })().finally(() => {
171
209
  // Only this load's own slot: a newer load that replaced an abandoned one keeps its place.
172
- if (this.inflight.get(key)?.promise === promise) this.inflight.delete(key)
210
+ if (this.inflight.get(key)?.token === token) this.inflight.delete(key)
173
211
  })
174
212
 
175
- this.inflight.set(key, { promise, startedAt: this.now() })
213
+ this.inflight.set(key, { promise, startedAt: this.now(), token })
176
214
  return promise
177
215
  }
178
216
 
@@ -189,9 +227,39 @@ export class TtlCache<V> {
189
227
  this.store.set(key, { value, expiresAt: this.now() + ttl, negative })
190
228
  }
191
229
 
230
+ /** 2.0.2: whether a load for `key` is running (and still joinable, younger than `inflightMaxMs`). */
231
+ loading(key: string): boolean {
232
+ const existing = this.inflight.get(key)
233
+ return existing !== undefined && this.now() - existing.startedAt < this.inflightMaxMs
234
+ }
235
+
236
+ /**
237
+ * 2.0.2: remember a transient failure for `key` for `failureTtlMs` from now (a caller that stopped waiting for a load, or
238
+ * a load that settled on one), beside the key's entry: it never touches a positive entry. Without `failureTtlMs` it is
239
+ * a negative entry for the negative TTL, as a transient result was before 2.0.2, and like any `set` it replaces the
240
+ * key's entry.
241
+ */
242
+ markFailed(key: string, value: V): void {
243
+ if (this.failureTtlMs <= 0) {
244
+ this.set(key, value, true)
245
+ return
246
+ }
247
+ // Bounded like the store (break-it B3): past `maxEntries`, drop the expired failures, then the oldest.
248
+ if (this.failures.size >= this.maxEntries && !this.failures.has(key)) {
249
+ const now = this.now()
250
+ for (const [k, f] of this.failures) if (f.until <= now) this.failures.delete(k)
251
+ if (this.failures.size >= this.maxEntries) {
252
+ const oldest = this.failures.keys().next().value
253
+ if (oldest !== undefined) this.failures.delete(oldest)
254
+ }
255
+ }
256
+ this.failures.set(key, { value, until: this.now() + this.failureTtlMs })
257
+ }
258
+
192
259
  /** Drop a key (e.g. on a known revocation push). */
193
260
  delete(key: string): void {
194
261
  this.store.delete(key)
262
+ this.failures.delete(key)
195
263
  }
196
264
 
197
265
  /**
@@ -205,11 +273,15 @@ export class TtlCache<V> {
205
273
  for (const key of this.store.keys()) {
206
274
  if (key.startsWith(prefix)) this.store.delete(key)
207
275
  }
276
+ for (const key of this.failures.keys()) {
277
+ if (key.startsWith(prefix)) this.failures.delete(key)
278
+ }
208
279
  }
209
280
 
210
281
  clear(): void {
211
282
  this.store.clear()
212
283
  this.inflight.clear()
284
+ this.failures.clear()
213
285
  }
214
286
 
215
287
  get size(): number {
@@ -427,6 +427,33 @@ export function peekVerifiedDelivery(
427
427
  return cached === undefined ? undefined : settleDelivery(cached, opts.context)
428
428
  }
429
429
 
430
+ /**
431
+ * 2.0.2: the settled answer for "CertREV was not reached in time": what a server render prints when it stops waiting
432
+ * (its deadline passed, or another request's read of the placement is in flight). Nothing, reported as
433
+ * `delivery_unreachable`, exactly as for a read that failed.
434
+ */
435
+ export function unreachableDelivery(): VerifiedDelivery {
436
+ return settleDelivery(unreachable(), { platform: 'builder', externalId: '', now: new Date(0) })
437
+ }
438
+
439
+ /**
440
+ * 2.0.2: remember that a read of one placement did not reach CertREV in time (a render stopped waiting for it), so the
441
+ * cache answers the placement at once for its failure window. A no-op on a cache with no failure window beyond 2.0.1's
442
+ * negative entry. Never touches a verified entry.
443
+ */
444
+ export function rememberDeliveryUnreachable(
445
+ opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'cache' | 'design'>,
446
+ ): void {
447
+ ;(opts.cache ?? sharedDeliveryCache).markFailed(deliveryCacheKey(opts), unreachable())
448
+ }
449
+
450
+ /** 2.0.2: whether a read of one placement is already running in `cache` (a render's, or a background refresh). */
451
+ export function deliveryLoading(
452
+ opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'cache' | 'design'>,
453
+ ): boolean {
454
+ return (opts.cache ?? sharedDeliveryCache).loading(deliveryCacheKey(opts))
455
+ }
456
+
430
457
  /** Drop the cached read for one placement (e.g. from a revocation webhook); with `design`, that pinned read. */
431
458
  export function invalidateDelivery(
432
459
  opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'design'>,