Skip to content

Commit 2bc9d54

Browse files
perf(cloudflare): cut KV data cache round trips from 3 to 2 per tagged hit (#3187)
* perf(cloudflare): cut KV data cache round trips A cold get() made three sequential KV round trips: the entry, then the entry's own tag markers, then the caller's soft-tag markers. Soft tags are known before the entry read, so their markers now start with it, and the second hop reads only the entry tags the first hop did not already cover. Markers go through KV's bulk get() in chunks of 100, so a page with many tags costs one subrequest per chunk instead of one per tag. A tagged hit now takes two round trips and an untagged hit takes one. Entry reads also gain an opt-in entryCacheTtlSeconds option that sets KV cacheTtl on the entry key alone; it stays off by default because a colo that cached the key can then serve a superseded value for that long. Tag markers never take it, since a colo cache on a marker would hide a revalidateTag from that colo for the same window. * fix(cloudflare): stop a KV marker read failure from breaking a miss - await the entry read alone, so a miss returns without the soft-tag batch and survives its failure - attach a catch to the ignored marker promise to prevent an unhandled rejection - check the locally cached entry tags before the second hop, which restores the short circuit the previous commit dropped - correct the entryCacheTtlSeconds doc: markers keep the KV default cacheTtl of 60 s rather than always reaching the central store - add a fail helper to the tracing KV double, with cases for the miss, hit, and cached invalidation paths * fix(cloudflare): respect tagCacheTtlMs in the invalidation fast path - check cached entry tags first, so a failed soft-tag batch cannot mask a known invalidation - add requireFresh to _hasRevalidatedTag, so a pre-prime check ignores a marker past tagCacheTtlMs - keep a marker written at or after the read start, so a detached prime cannot overwrite a newer revalidateTag - snapshot the store at call time in the tracing KV double, so a held read cannot see a later write - add four tests; all four fail against 7623ce3 * fix(cloudflare): keep speculative KV reads request-scoped * fix(cloudflare): order concurrent KV tag primes * fix(cloudflare): re-prime KV tags after cache reset * fix(cloudflare): preserve KV tag validation across reset * fix(cloudflare): avoid deleting newer KV entries --------- Co-authored-by: James <james@eli.cx>
1 parent 86afc11 commit 2bc9d54

4 files changed

Lines changed: 680 additions & 62 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,7 @@ The KV data adapter reads `env[binding]` at runtime, so add the matching KV name
703703
}
704704
```
705705

706-
`binding` defaults to `VINEXT_KV_CACHE`, so `kvDataAdapter()` with no options works as long as that's your binding name. Other options: `appPrefix` (namespace cache keys to isolate multiple apps in one KV namespace), `ttlSeconds` (default KV `expirationTtl`, default 30 days), and `tagCacheTtlMs` (in-memory tag-invalidation cache TTL, default 5s).
706+
`binding` defaults to `VINEXT_KV_CACHE`, so `kvDataAdapter()` with no options works as long as that's your binding name. Other options: `appPrefix` (namespace cache keys to isolate multiple apps in one KV namespace), `ttlSeconds` (default KV `expirationTtl`, default 30 days), `tagCacheTtlMs` (in-memory tag-invalidation cache TTL, default 5s), and `entryCacheTtlSeconds` (optional KV edge-cache TTL for entry reads; tag markers keep KV's default).
707707

708708
When `cdnAdapter()` is used in a Cloudflare build, vinext emits two Worker
709709
entrypoints and configures Workers Cache only on the response entrypoint. The

packages/cloudflare/src/cache/kv-data-adapter.runtime.ts

Lines changed: 161 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,13 @@ type SerializedIncrementalCacheValue =
6666

6767
// Cloudflare KV namespace interface (matches Workers types)
6868
type KVNamespace = {
69-
get(key: string, options?: { type?: string }): Promise<string | null>;
69+
get(key: string, options?: { type?: string; cacheTtl?: number }): Promise<string | null>;
7070
get(key: string, options: { type: "arrayBuffer" }): Promise<ArrayBuffer | null>;
71+
/** Bulk read, capped at 100 keys per call. */
72+
get(
73+
keys: string[],
74+
options?: { type?: string; cacheTtl?: number },
75+
): Promise<Map<string, string | null>>;
7176
put(
7277
key: string,
7378
value: string | ArrayBuffer | ReadableStream,
@@ -100,6 +105,12 @@ const PATH_TAG_PREFIX = "_N_T_";
100105
/** Max tag length to prevent KV key abuse. */
101106
const MAX_TAG_LENGTH = 256;
102107

108+
/** The runtime rejects a lower `cacheTtl` with "Cache TTL must be at least 30". */
109+
const MIN_KV_CACHE_TTL_SECONDS = 30;
110+
111+
/** Cloudflare caps a bulk `get()` at 100 keys per call. */
112+
const KV_BULK_GET_LIMIT = 100;
113+
103114
/** Matches a valid base64 string (standard alphabet with optional padding). */
104115
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
105116

@@ -184,10 +195,15 @@ export class KVCacheHandler implements CacheHandler {
184195
private ttlSeconds: number;
185196

186197
/** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */
187-
private _tagCache = new Map<string, { timestamp: number; fetchedAt: number }>();
198+
private _tagCache = new Map<string, { timestamp: number; fetchedAt: number; order: number }>();
199+
/** Monotonic ordering for concurrent tag-cache fills and local invalidations. */
200+
private _tagCacheOrder = 0;
188201
/** TTL (ms) for local tag cache entries. After this, re-fetch from KV. */
189202
private _tagCacheTtl: number;
190203

204+
/** Read options for entry keys only. `undefined` keeps the KV default cacheTtl. */
205+
private _entryReadOptions: { cacheTtl: number } | undefined;
206+
191207
constructor(
192208
kvNamespace: KVNamespace,
193209
options?: {
@@ -196,13 +212,20 @@ export class KVCacheHandler implements CacheHandler {
196212
ttlSeconds?: number;
197213
/** TTL in milliseconds for the local tag cache. Defaults to 5000ms. */
198214
tagCacheTtlMs?: number;
215+
/** KV `cacheTtl` in seconds for entry reads. Off by default, never used for tag markers. */
216+
entryCacheTtlSeconds?: number;
199217
},
200218
) {
201219
this.kv = kvNamespace;
202220
this.keySpace = createKvKeySpace(options?.appPrefix);
203221
this.ctx = options?.ctx;
204222
this.ttlSeconds = options?.ttlSeconds ?? 30 * 24 * 3600;
205223
this._tagCacheTtl = options?.tagCacheTtlMs ?? 5_000;
224+
const entryCacheTtl = options?.entryCacheTtlSeconds;
225+
this._entryReadOptions =
226+
typeof entryCacheTtl === "number" && Number.isFinite(entryCacheTtl)
227+
? { cacheTtl: Math.max(MIN_KV_CACHE_TTL_SECONDS, Math.floor(entryCacheTtl)) }
228+
: undefined;
206229
}
207230

208231
private _entryKey(key: string): string {
@@ -215,24 +238,42 @@ export class KVCacheHandler implements CacheHandler {
215238

216239
async get(key: string, _ctx?: Record<string, unknown>): Promise<CacheHandlerValue | null> {
217240
const kvKey = this._entryKey(key);
218-
const raw = await this.kv.get(kvKey);
241+
const softTags = validUniqueTags(readStringArrayField(_ctx, "softTags"));
242+
// Soft tags are known before the entry arrives, so their markers ride the
243+
// same hop instead of costing a round trip after it.
244+
const entryRead = this._entryReadOptions
245+
? this.kv.get(kvKey, this._entryReadOptions)
246+
: this.kv.get(kvKey);
247+
let softTagCache = this._tagCache;
248+
const softTagPrime = this._primeTagCache(softTags);
249+
if (softTags.length > 0) {
250+
// A miss read no marker before this change, so a marker failure must not
251+
// reject it. Keep the speculative work alive, while a hit still awaits
252+
// the original promise below and propagates its failure.
253+
const backgroundPrime = softTagPrime.catch(() => {});
254+
const ctx = getRequestExecutionContext() ?? this.ctx;
255+
if (ctx) ctx.waitUntil(backgroundPrime);
256+
else void backgroundPrime;
257+
}
258+
259+
const raw = await entryRead;
219260
if (!raw) return null;
220261

221262
let parsed: unknown;
222263
try {
223264
parsed = JSON.parse(raw);
224265
} catch {
225-
// Corrupted JSON — fire cleanup delete in the background and treat as miss.
266+
// Corrupted JSON — clean up when safe and treat as a miss.
226267
// Using waitUntil ensures the delete isn't killed when the Response is returned.
227-
this._deleteInBackground(kvKey);
268+
this._deleteEntryReadInBackground(kvKey);
228269
return null;
229270
}
230271

231272
// Validate deserialized shape before using
232273
const entry = validateCacheEntry(parsed);
233274
if (!entry) {
234275
console.error("[vinext] Invalid cache entry shape for key:", key);
235-
this._deleteInBackground(kvKey);
276+
this._deleteEntryReadInBackground(kvKey);
236277
return null;
237278
}
238279

@@ -242,23 +283,52 @@ export class KVCacheHandler implements CacheHandler {
242283
restoredValue = restoreArrayBuffers(entry.value);
243284
if (!restoredValue) {
244285
// base64 decode failed — corrupted entry, treat as miss
245-
this._deleteInBackground(kvKey);
286+
this._deleteEntryReadInBackground(kvKey);
246287
return null;
247288
}
248289
}
249290

250-
if (await this._hasRevalidatedTag(validUniqueTags(entry.tags), entry.lastModified)) {
251-
this._deleteInBackground(kvKey);
291+
const entryTags = validUniqueTags(entry.tags);
292+
293+
// A marker an earlier read already cached settles the entry on its own, so
294+
// check before awaiting reads whose failure would otherwise mask it.
295+
if (this._hasRevalidatedTag(entryTags, entry.lastModified, true)) {
296+
this._deleteEntryReadInBackground(kvKey);
252297
return null;
253298
}
254299

255-
const softTags = validUniqueTags(readStringArrayField(_ctx, "softTags"));
256-
if (await this._hasRevalidatedTag(softTags, entry.lastModified)) {
300+
await softTagPrime;
301+
// resetRequestCache() may have replaced the Map while the first read was
302+
// in flight. Prime each newer generation before consulting its markers.
303+
while (softTagCache !== this._tagCache) {
304+
softTagCache = this._tagCache;
305+
await this._primeTagCache(softTags);
306+
}
307+
308+
// The soft-tag batch may have covered an entry tag too, which spares the
309+
// second hop. Only the post-prime check trusts an entry past its TTL.
310+
let invalidated = this._hasRevalidatedTag(entryTags, entry.lastModified, true);
311+
if (!invalidated) {
312+
await this._primeTagCache(entryTags);
313+
// The entry-tag hop can race a reset too. Re-prime the complete
314+
// validation set until one cache generation survives the whole read.
315+
while (softTagCache !== this._tagCache) {
316+
softTagCache = this._tagCache;
317+
await this._primeTagCache([...new Set([...softTags, ...entryTags])]);
318+
}
319+
invalidated = this._hasRevalidatedTag(entryTags, entry.lastModified);
320+
}
321+
if (invalidated) {
322+
this._deleteEntryReadInBackground(kvKey);
323+
return null;
324+
}
325+
326+
if (this._hasRevalidatedTag(softTags, entry.lastModified)) {
257327
return null;
258328
}
259329

260330
if (entry.expireAt !== undefined && entry.expireAt !== null && Date.now() > entry.expireAt) {
261-
this._deleteInBackground(kvKey);
331+
this._deleteEntryReadInBackground(kvKey);
262332
return null;
263333
}
264334

@@ -288,54 +358,82 @@ export class KVCacheHandler implements CacheHandler {
288358
}
289359

290360
/**
291-
* Check tag invalidation markers for stored tags or read-time soft tags.
292-
* Uses a local in-memory cache to avoid redundant KV reads for recently-seen tags.
361+
* Load the invalidation markers these tags still need into the local cache.
362+
* Tags a recent read already cached cost nothing, so the entry-tag batch only
363+
* pays for what the soft-tag batch did not already fetch.
293364
*/
294-
private async _hasRevalidatedTag(tags: string[], lastModified: number): Promise<boolean> {
295-
if (tags.length === 0) return false;
365+
private async _primeTagCache(tags: string[]): Promise<void> {
366+
if (tags.length === 0) return;
296367

368+
// Keep fills on the cache generation they started against. resetRequestCache()
369+
// swaps the Map so an older detached fill cannot repopulate the cleared cache.
370+
const tagCache = this._tagCache;
297371
const now = Date.now();
298-
const uncachedTags: string[] = [];
372+
// Drop expired entries to prevent unbounded Map growth in long-lived isolates.
373+
const missing = tags.filter((tag) => {
374+
const cached = tagCache.get(tag);
375+
if (cached && now - cached.fetchedAt < this._tagCacheTtl) return false;
376+
if (cached) tagCache.delete(tag);
377+
return true;
378+
});
379+
if (missing.length === 0) return;
380+
381+
const order = ++this._tagCacheOrder;
382+
const markers = await this._readTagMarkers(missing.map((tag) => this._tagKey(tag)));
383+
for (const tag of missing) {
384+
// A revalidateTag() landed while this read was in flight. Its marker is
385+
// newer than anything this read can report, so leave it in place. The
386+
// order also distinguishes concurrent reads started in the same millisecond.
387+
const current = tagCache.get(tag);
388+
if (current && current.order >= order) continue;
389+
const marker = markers.get(this._tagKey(tag));
390+
tagCache.set(tag, { timestamp: marker ? Number(marker) : 0, fetchedAt: now, order });
391+
}
392+
}
299393

300-
// First pass: check local cache for each tag.
301-
// Delete expired entries to prevent unbounded Map growth in long-lived isolates.
302-
for (const tag of tags) {
303-
const cached = this._tagCache.get(tag);
304-
if (cached && now - cached.fetchedAt < this._tagCacheTtl) {
305-
// Local cache hit — check invalidation inline
306-
if (Number.isNaN(cached.timestamp) || cached.timestamp >= lastModified) {
307-
return true;
308-
}
309-
} else {
310-
// Expired or absent — evict stale entry and re-fetch from KV
311-
if (cached) this._tagCache.delete(tag);
312-
uncachedTags.push(tag);
313-
}
394+
/**
395+
* Read every marker key, one round trip per 100-key chunk.
396+
*
397+
* Markers never take the entry `cacheTtl`: `revalidateTag()` writes them, so
398+
* a longer colo cache would hide a publish from that colo for its span. They
399+
* keep KV's own default cacheTtl of 60 s.
400+
*/
401+
private async _readTagMarkers(keys: string[]): Promise<Map<string, string | null>> {
402+
if (keys.length === 1) {
403+
return new Map([[keys[0], await this.kv.get(keys[0])]]);
314404
}
315405

316-
// Second pass: fetch uncached tags from KV in parallel.
317-
// Populate the local cache for ALL fetched tags before checking invalidation,
318-
// so subsequent get() calls benefit from the already-fetched results.
319-
if (uncachedTags.length > 0) {
320-
const tagResults = await Promise.all(
321-
uncachedTags.map((tag) => this.kv.get(this._tagKey(tag))),
322-
);
323-
324-
for (let i = 0; i < uncachedTags.length; i++) {
325-
const tagTime = tagResults[i];
326-
const tagTimestamp = tagTime ? Number(tagTime) : 0;
327-
this._tagCache.set(uncachedTags[i], { timestamp: tagTimestamp, fetchedAt: now });
328-
}
406+
const chunks: string[][] = [];
407+
for (let i = 0; i < keys.length; i += KV_BULK_GET_LIMIT) {
408+
chunks.push(keys.slice(i, i + KV_BULK_GET_LIMIT));
409+
}
410+
const results = await Promise.all(chunks.map((chunk) => this.kv.get(chunk)));
329411

330-
for (const tag of uncachedTags) {
331-
const cached = this._tagCache.get(tag);
332-
if (!cached || cached.timestamp === 0) continue;
333-
if (Number.isNaN(cached.timestamp) || cached.timestamp >= lastModified) {
334-
return true;
335-
}
412+
const markers = new Map<string, string | null>();
413+
for (const result of results) {
414+
for (const [key, value] of result) {
415+
markers.set(key, value);
336416
}
337417
}
418+
return markers;
419+
}
338420

421+
/**
422+
* Report whether any tag has an invalidation marker at or after `lastModified`.
423+
* Call `_primeTagCache` for the same tags first — a tag with no cache entry
424+
* counts as never invalidated. Pass `requireFresh` to call it before a prime,
425+
* so an entry past `tagCacheTtlMs` does not answer for a tag it never re-read.
426+
*/
427+
private _hasRevalidatedTag(tags: string[], lastModified: number, requireFresh = false): boolean {
428+
const now = requireFresh ? Date.now() : 0;
429+
for (const tag of tags) {
430+
const cached = this._tagCache.get(tag);
431+
if (!cached || cached.timestamp === 0) continue;
432+
if (requireFresh && now - cached.fetchedAt >= this._tagCacheTtl) continue;
433+
if (Number.isNaN(cached.timestamp) || cached.timestamp >= lastModified) {
434+
return true;
435+
}
436+
}
339437
return false;
340438
}
341439

@@ -446,10 +544,11 @@ export class KVCacheHandler implements CacheHandler {
446544
}),
447545
),
448546
);
547+
const order = ++this._tagCacheOrder;
449548
// Update local tag cache immediately so invalidations are reflected
450549
// without waiting for the TTL to expire
451550
for (const tag of validTags) {
452-
this._tagCache.set(tag, { timestamp: now, fetchedAt: now });
551+
this._tagCache.set(tag, { timestamp: now, fetchedAt: now, order });
453552
}
454553
}
455554

@@ -513,7 +612,16 @@ export class KVCacheHandler implements CacheHandler {
513612
* fresh KVCacheHandler per request or invoke this method explicitly.
514613
*/
515614
resetRequestCache(): void {
516-
this._tagCache.clear();
615+
this._tagCache = new Map();
616+
}
617+
618+
/**
619+
* Clean up a bad entry only when this read cannot be an explicitly cached
620+
* older version of the shared KV value. KV has no conditional delete, so a
621+
* cached read must remain a non-destructive miss.
622+
*/
623+
private _deleteEntryReadInBackground(kvKey: string): void {
624+
if (!this._entryReadOptions) this._deleteInBackground(kvKey);
517625
}
518626

519627
/**
@@ -709,6 +817,7 @@ const createKvDataCacheAdapter = ({
709817
appPrefix: options?.appPrefix,
710818
ttlSeconds: options?.ttlSeconds,
711819
tagCacheTtlMs: options?.tagCacheTtlMs,
820+
entryCacheTtlSeconds: options?.entryCacheTtlSeconds,
712821
});
713822
};
714823

packages/cloudflare/src/cache/kv-data-adapter.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ export type KvDataAdapterOptions = {
1212
ttlSeconds?: number;
1313
/** TTL in milliseconds for the in-memory tag-invalidation cache. @default 5000 */
1414
tagCacheTtlMs?: number;
15+
/**
16+
* KV `cacheTtl` in seconds for entry reads, letting a colo answer a repeat
17+
* read from its own cache instead of the central store. The runtime rejects
18+
* a value below 30, so lower values are raised to 30.
19+
*
20+
* Trade-off: after a `set()`, a colo that already cached the key can serve
21+
* the superseded value for up to this long.
22+
*
23+
* It applies to entry reads only. Tag markers, which `revalidateTag()` and
24+
* `revalidatePath()` write, keep KV's own default cacheTtl of 60 s rather
25+
* than this longer one, so this option never widens the window in which a
26+
* colo can miss a publish. That window stays `tagCacheTtlMs` plus whatever
27+
* the KV default cache holds, with or without this option.
28+
*
29+
* @default undefined (entry reads keep KV's default cacheTtl of 60 s)
30+
*/
31+
entryCacheTtlSeconds?: number;
1532
};
1633

1734
/**

0 commit comments

Comments
 (0)