perf(kv): local tag cache to reduce KV round-trips on cache hit#433
perf(kv): local tag cache to reduce KV round-trips on cache hit#433james-elicx merged 5 commits intocloudflare:mainfrom
Conversation
When KVCacheHandler.get() validates tags, it was issuing one kv.get()
per tag in parallel. For entries with N tags, that's N KV round-trips
per cache hit. Same pattern in revalidateTag() — N parallel PUTs.
Add a local Map<string, { timestamp, fetchedAt }> with a 5-second TTL
that caches tag invalidation timestamps. Within the TTL window, tag
checks are served from memory with zero I/O. After TTL expiry, the
next request re-fetches from KV.
Key behaviors:
- revalidateTag() updates the local cache immediately so invalidations
are reflected without waiting for TTL expiry
- resetRequestCache() clears the local cache for per-request isolation
- NaN tag timestamps are cached and correctly treated as invalidation
- Only uncached/expired tags trigger KV reads (partial cache hits work)
commit: |
There was a problem hiding this comment.
Pull request overview
This PR reduces Cloudflare KV round-trips on cache hits by adding a short-lived in-memory cache of tag invalidation timestamps inside KVCacheHandler, with tests to validate behavior across cache hits, TTL expiry, and tag invalidation flows.
Changes:
- Add a local in-memory tag timestamp cache with a 5s TTL to avoid repeated
kv.get()calls for tags. - Update
revalidateTag()to immediately update the in-memory tag cache. - Add a new test suite covering local tag cache hit behavior, TTL expiry, partial cache hits, and NaN/corrupt tag timestamps.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
packages/vinext/src/cloudflare/kv-cache-handler.ts |
Adds local tag cache + integrates it into tag invalidation checks and revalidateTag(). |
tests/kv-cache-handler.test.ts |
Adds tests validating local tag cache behavior, TTL refresh, and invalidation handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */ | ||
| private _tagCache = new Map<string, { timestamp: number; fetchedAt: number }>(); | ||
| /** TTL (ms) for local tag cache entries. After this, re-fetch from KV. */ | ||
| private static TAG_CACHE_TTL = 5_000; |
There was a problem hiding this comment.
The local _tagCache is never pruned: TTL is only checked for freshness, but entries remain in the Map indefinitely unless resetRequestCache() is called. In a long-lived Worker with many unique tags, this can grow unbounded. Consider deleting stale entries when now - fetchedAt >= TAG_CACHE_TTL, and/or adding a max-size/LRU cap (or scheduled cleanup) so the cache can't accumulate forever.
| // First pass: check local cache for each tag | ||
| for (const tag of entry.tags) { | ||
| const cached = this._tagCache.get(tag); | ||
| if (cached && now - cached.fetchedAt < KVCacheHandler.TAG_CACHE_TTL) { | ||
| // Local cache hit — check invalidation inline | ||
| if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) { | ||
| this._deleteInBackground(kvKey); | ||
| return null; | ||
| } | ||
| } else { | ||
| uncachedTags.push(tag); | ||
| } |
There was a problem hiding this comment.
entry.tags is assumed to be a string array here, but validateCacheEntry() only checks Array.isArray(tags) and does not validate element types. If a corrupted/malicious KV entry contains non-string tags (e.g., objects), _tagCache will store them as Map keys (preventing GC) and KV key construction will coerce them unexpectedly. Suggest filtering entry.tags to typeof tag === 'string' and/or applying validateTag() before using/caching each tag.
| this._tagCache.set(tag, { timestamp: now, fetchedAt: now }); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
If this resetRequestCache() is meant to provide per-request isolation, it currently isn't called anywhere in this repo (only defined). Either wire it into the request lifecycle (where the cache handler is used) or clarify that _tagCache is intentionally cross-request; otherwise it’s easy to assume isolation that isn’t actually happening.
| /** | |
| * Manually clear the in-memory tag cache for this KVCacheHandler instance. | |
| * | |
| * Note: KVCacheHandler instances are typically reused across multiple | |
| * requests in a Cloudflare Worker, and `_tagCache` is intentionally shared | |
| * across those requests as an optimization. vinext does NOT automatically | |
| * call this method per request, so there is no implicit per-request | |
| * isolation of tag state. | |
| * | |
| * This method is provided as an explicit escape hatch for environments or | |
| * tests that need stricter isolation and are managing handler instances or | |
| * lifecycle hooks themselves. Callers who require per-request isolation | |
| * should either: | |
| * - construct a fresh KVCacheHandler per request, or | |
| * - invoke resetRequestCache() at an appropriate point in their own | |
| * request lifecycle. | |
| */ |
tests/kv-cache-handler.test.ts
Outdated
| const now = 10_000; | ||
| vi.setSystemTime(now); | ||
|
|
||
| const entryTime = 1000; | ||
| store.set( | ||
| "cache:ttl-page", | ||
| JSON.stringify({ | ||
| value: { kind: "PAGES", html: "<p>hi</p>", pageData: {}, status: 200 }, | ||
| tags: ["t1"], | ||
| lastModified: entryTime, | ||
| revalidateAt: null, | ||
| }), | ||
| ); | ||
|
|
||
| // First get() — populates local tag cache | ||
| await handler.get("ttl-page"); | ||
| expect(kv.get).toHaveBeenCalledTimes(2); // entry + tag | ||
| kv.get.mockClear(); | ||
|
|
||
| // Second get() within TTL — uses local cache | ||
| vi.setSystemTime(now + 4_000); // 4s < 5s TTL | ||
| await handler.get("ttl-page"); | ||
| expect(kv.get).toHaveBeenCalledTimes(1); // entry only | ||
| kv.get.mockClear(); | ||
|
|
||
| // Third get() after TTL — must re-fetch from KV | ||
| vi.setSystemTime(now + 6_000); // 6s > 5s TTL | ||
| await handler.get("ttl-page"); | ||
| expect(kv.get).toHaveBeenCalledTimes(2); // entry + tag | ||
|
|
||
| vi.useRealTimers(); |
There was a problem hiding this comment.
This test enables fake timers and restores real timers at the end. If an assertion throws before vi.useRealTimers(), fake timers can leak into subsequent tests and cause cascading failures. Consider using try/finally within the test or an afterEach(() => vi.useRealTimers()) guard for this describe block.
| const now = 10_000; | |
| vi.setSystemTime(now); | |
| const entryTime = 1000; | |
| store.set( | |
| "cache:ttl-page", | |
| JSON.stringify({ | |
| value: { kind: "PAGES", html: "<p>hi</p>", pageData: {}, status: 200 }, | |
| tags: ["t1"], | |
| lastModified: entryTime, | |
| revalidateAt: null, | |
| }), | |
| ); | |
| // First get() — populates local tag cache | |
| await handler.get("ttl-page"); | |
| expect(kv.get).toHaveBeenCalledTimes(2); // entry + tag | |
| kv.get.mockClear(); | |
| // Second get() within TTL — uses local cache | |
| vi.setSystemTime(now + 4_000); // 4s < 5s TTL | |
| await handler.get("ttl-page"); | |
| expect(kv.get).toHaveBeenCalledTimes(1); // entry only | |
| kv.get.mockClear(); | |
| // Third get() after TTL — must re-fetch from KV | |
| vi.setSystemTime(now + 6_000); // 6s > 5s TTL | |
| await handler.get("ttl-page"); | |
| expect(kv.get).toHaveBeenCalledTimes(2); // entry + tag | |
| vi.useRealTimers(); | |
| try { | |
| const now = 10_000; | |
| vi.setSystemTime(now); | |
| const entryTime = 1000; | |
| store.set( | |
| "cache:ttl-page", | |
| JSON.stringify({ | |
| value: { kind: "PAGES", html: "<p>hi</p>", pageData: {}, status: 200 }, | |
| tags: ["t1"], | |
| lastModified: entryTime, | |
| revalidateAt: null, | |
| }), | |
| ); | |
| // First get() — populates local tag cache | |
| await handler.get("ttl-page"); | |
| expect(kv.get).toHaveBeenCalledTimes(2); // entry + tag | |
| kv.get.mockClear(); | |
| // Second get() within TTL — uses local cache | |
| vi.setSystemTime(now + 4_000); // 4s < 5s TTL | |
| await handler.get("ttl-page"); | |
| expect(kv.get).toHaveBeenCalledTimes(1); // entry only | |
| kv.get.mockClear(); | |
| // Third get() after TTL — must re-fetch from KV | |
| vi.setSystemTime(now + 6_000); // 6s > 5s TTL | |
| await handler.get("ttl-page"); | |
| expect(kv.get).toHaveBeenCalledTimes(2); // entry + tag | |
| } finally { | |
| vi.useRealTimers(); | |
| } |
Wrap vi.useFakeTimers() in try/finally so vi.useRealTimers() runs even if an assertion fails mid-test, preventing timer leaks into subsequent tests.
|
/bigbonk review |
There was a problem hiding this comment.
Review: Local tag cache for KV round-trip reduction
Good optimization — the two-pass approach (check local cache, then fetch uncached from KV) is clean and the test coverage is solid. The core logic is correct. I have a few issues to flag, one of which is a real bug (missed caching on early return), and the rest are hardening suggestions.
All 36 tests pass locally.
| if (tagTime) { | ||
| if (Number.isNaN(tagTimestamp) || tagTimestamp >= entry.lastModified) { | ||
| this._deleteInBackground(kvKey); | ||
| return null; |
There was a problem hiding this comment.
Bug: early return discards already-fetched tag results.
The Promise.all on line 171 already resolved all tag fetches, but when an invalidating tag is found at index i, tags at indices i+1..n are never written to _tagCache. Those KV round-trips already happened — their results should be cached so subsequent get() calls don't re-fetch them.
Move the cache-population loop before the invalidation check, or populate all results before iterating for invalidation:
| return null; | |
| // Populate local cache (0 for missing tags, NaN for corrupted) | |
| this._tagCache.set(uncachedTags[i], { timestamp: tagTimestamp, fetchedAt: now }); | |
| } | |
| // Check invalidation after populating cache for all fetched tags | |
| for (let i = 0; i < uncachedTags.length; i++) { | |
| const tagTime = tagResults[i]; | |
| const tagTimestamp = tagTime ? Number(tagTime) : 0; | |
| if (tagTime) { | |
| if (Number.isNaN(tagTimestamp) || tagTimestamp >= entry.lastModified) { | |
| this._deleteInBackground(kvKey); | |
| return null; | |
| } | |
| } |
Alternatively, a simpler refactor: split into two loops — one to populate cache for all results, one to check invalidation.
| private ttlSeconds: number; | ||
|
|
||
| /** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */ | ||
| private _tagCache = new Map<string, { timestamp: number; fetchedAt: number }>(); |
There was a problem hiding this comment.
Unbounded growth: The _tagCache Map is never pruned. TTL-expired entries remain in the map indefinitely — they're just treated as cache misses and overwritten on the next fetch. In a long-lived Worker with many unique tags, this grows without bound.
Since resetRequestCache() is never called in the vinext request lifecycle (see below), this map will accumulate entries for the lifetime of the isolate.
Consider either:
- Deleting stale entries when they're encountered (cheap: just add
else { this._tagCache.delete(tag); }after the TTL check in the first pass), or - Adding a max-size cap and evicting oldest entries when exceeded.
|
|
||
| resetRequestCache(): void { | ||
| // No-op — KV is stateless per request | ||
| this._tagCache.clear(); |
There was a problem hiding this comment.
resetRequestCache() is never called.
Grep confirms that nothing in packages/vinext/src/ calls .resetRequestCache() on any cache handler. The PR description says "clears the local cache for per-request isolation" but that isolation doesn't actually happen — this is dead code in production.
This isn't necessarily wrong (the 5s TTL provides a staleness bound regardless), but the claim of "per-request isolation" in the PR description is misleading. The _tagCache is shared across all requests hitting the same Worker isolate.
Suggestions:
- Either wire
resetRequestCache()into the request lifecycle (e.g., call it at the start of each request in the worker entry), or - Update the docstring to clarify this is intentionally cross-request and the method is an opt-in escape hatch.
If per-request isolation isn't needed (and the 5s TTL is the intended staleness bound), option 2 is fine. But the current state is a documentation/expectation mismatch.
| /** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */ | ||
| private _tagCache = new Map<string, { timestamp: number; fetchedAt: number }>(); | ||
| /** TTL (ms) for local tag cache entries. After this, re-fetch from KV. */ | ||
| private static TAG_CACHE_TTL = 5_000; |
There was a problem hiding this comment.
Nit: Consider making this configurable via the constructor options (e.g., tagCacheTtlMs?: number). The 5s default is reasonable for Workers, but testing and non-Workers environments might benefit from different values. Would also eliminate the need for fake timers in the TTL test — you could just pass tagCacheTtlMs: 0.
Not a blocker.
| const cached = this._tagCache.get(tag); | ||
| if (cached && now - cached.fetchedAt < KVCacheHandler.TAG_CACHE_TTL) { | ||
| // Local cache hit — check invalidation inline | ||
| if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) { |
There was a problem hiding this comment.
Nit: The timestamp >= entry.lastModified check uses >= which means a tag invalidated at the exact same millisecond as lastModified triggers invalidation. This matches the original code's behavior so it's not a regression, just calling it out — it means revalidateTag() called in the same millisecond as set() will invalidate the entry. Seems like the right call.
| // ------------------------------------------------------------------------- | ||
|
|
||
| describe("local tag cache", () => { | ||
| it("cached tags skip KV on second get()", async () => { |
There was a problem hiding this comment.
Good test coverage. One missing case worth adding: resetRequestCache() actually clears the local cache. There's no test that calls handler.resetRequestCache() and then verifies that tags are re-fetched from KV on the next get(). This would validate the only public API for clearing the cache.
Something like:
it("resetRequestCache() forces tags to be re-fetched from KV", async () => {
// ... setup entry with tags, do first get() to populate cache ...
kv.get.mockClear();
handler.resetRequestCache();
await handler.get("tagged-page");
// Should fetch tags from KV again
expect(kv.get).toHaveBeenCalledTimes(3); // entry + 2 tags
});|
Review submitted. Here's a summary of the feedback I posted on PR #433: Review Summary1 bug, 3 improvement suggestions, 2 nits. Bug — Early return discards already-fetched tag results ( Unbounded
Consider making Missing test: |
- Fix early return bug: split KV tag fetch loop into populate-cache pass then invalidation-check pass, so already-fetched tag results are always cached even when an earlier tag triggers an early return - Fix unbounded _tagCache growth: delete stale entries when encountered during the TTL check (cheap eviction instead of accumulation forever) - Make tag cache TTL configurable via tagCacheTtlMs constructor option (default 5000ms); eliminates fake timers in TTL test - Clarify resetRequestCache() docstring: it is not called per-request by vinext, it is an opt-in escape hatch for callers that need explicit isolation - Add resetRequestCache() test: verifies tags are re-fetched from KV after calling resetRequestCache()
The second pass over uncachedTags was re-calling Number(tagTime) and re-reading tagResults[i]. Now it reads directly from _tagCache (which was just populated in the first pass), eliminating the duplicate number conversion and making the intent clearer.
james-elicx
left a comment
There was a problem hiding this comment.
I did a pass over the full diff. Three things spotted and fixed in the latest commits; noting them here for the record.
Redundant Number(tagTime) conversion in the second pass
After the populate loop stores { timestamp: Number(tagTime) } into _tagCache, the invalidation loop was re-reading tagResults[i] and calling Number(tagTime) a second time. Fixed by reading the already-computed value from _tagCache in the check loop, which also makes the two-loop structure more obviously consistent:
// before
for (let i = 0; i < uncachedTags.length; i++) {
const tagTime = tagResults[i];
if (tagTime) {
const tagTimestamp = Number(tagTime); // computed again
if (Number.isNaN(tagTimestamp) || tagTimestamp >= entry.lastModified) { ... }
}
}
// after
for (const tag of uncachedTags) {
const cached = this._tagCache.get(tag)!; // already computed and stored
if (cached.timestamp !== 0) {
if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) { ... }
}
}TTL test used fake timers unnecessarily
The test for TTL expiry used vi.useFakeTimers() + vi.setSystemTime() to simulate time passing. Now that tagCacheTtlMs is configurable (added in the previous commit), the test can pass tagCacheTtlMs: 0 to make entries expire immediately — no fake timers, no timer leak risk if an assertion throws:
// before: fake timers, try/finally, setSystemTime dance
// after
const shortTtlHandler = new KVCacheHandler(kv as any, { tagCacheTtlMs: 0 });
// first get() populates cache; second get() sees it immediately expired and re-fetchesNo other issues found. The core logic (two-pass populate-then-check, stale entry eviction, revalidateTag updating the local cache immediately) all look correct. The resetRequestCache docstring is accurate. Test coverage is solid.
|
/bigbonk review |
There was a problem hiding this comment.
Review: Local tag cache for KV round-trip reduction
Good optimization, well-structured after the iteration with the other reviewers. The two-pass approach (populate cache, then check invalidation) correctly avoids wasting already-completed KV fetches. The stale entry eviction on line 163 addresses unbounded growth. Test coverage is solid with 7 new tests covering the key scenarios.
The previous review issues (early return discarding fetched results, unbounded _tagCache, fake timer leakage, resetRequestCache docstring) have all been addressed in the latest commits. Code looks correct.
One minor observation below, not a blocker.
| // Local cache hit — check invalidation inline | ||
| if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) { | ||
| this._deleteInBackground(kvKey); | ||
| return null; |
There was a problem hiding this comment.
Nit: When a locally-cached tag triggers invalidation (early return here), the remaining tags in entry.tags that were in the uncachedTags list never get fetched. This is fine for correctness (entry is invalid regardless), but means those tags won't be warmed in the local cache for other entries that share them.
This is the right tradeoff — avoiding unnecessary KV reads on an already-invalidated entry is more valuable than speculatively warming the cache. Just calling it out for documentation purposes.
| // Delete expired entries to prevent unbounded Map growth in long-lived isolates. | ||
| for (const tag of entry.tags) { | ||
| const cached = this._tagCache.get(tag); | ||
| if (cached && now - cached.fetchedAt < this._tagCacheTtl) { |
There was a problem hiding this comment.
Minor: now - cached.fetchedAt < this._tagCacheTtl means a TTL of 0 will always be expired (since now - fetchedAt is always >= 0). This is correct and the TTL=0 test relies on it, but it's worth noting that tagCacheTtlMs: 0 effectively disables the local cache entirely, which is a useful property for testing.
|
Review submitted. Here's a summary: Approved the PR. The optimization is well-structured and correct after the iteration with previous reviewers:
All 40 tests pass. The previous review issues have been addressed in the latest commits. Two minor inline observations posted (not blockers). |
Closes #439
Summary
Mapwith 5-second TTL toKVCacheHandlerthat caches tag invalidation timestampskv.get()calls for recently-seen tags — an entry with 20 tags no longer triggers 20 KV round-trips on every cache hit within the TTL windowrevalidateTag()updates the local cache immediately so invalidations are reflected without waiting for TTL expiryresetRequestCache()clears the local cache for per-request isolationTest plan
tests/kv-cache-handler.test.tscovering:get()revalidateTag()updates local cache (no KV GET for invalidated tags)