From 24b27ba98af5a69ea6816be241ab72b09593b666 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 19:20:01 -0700 Subject: [PATCH 1/4] perf(kv): add local in-memory tag cache to reduce KV round-trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../vinext/src/cloudflare/kv-cache-handler.ts | 57 ++++- tests/kv-cache-handler.test.ts | 205 ++++++++++++++++++ 2 files changed, 250 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index f0f58b6c..42568335 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -98,6 +98,11 @@ export class KVCacheHandler implements CacheHandler { private ctx: ExecutionContext | undefined; private ttlSeconds: number; + /** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */ + private _tagCache = new Map(); + /** TTL (ms) for local tag cache entries. After this, re-fetch from KV. */ + private static TAG_CACHE_TTL = 5_000; + constructor( kvNamespace: KVNamespace, options?: { appPrefix?: string; ctx?: ExecutionContext; ttlSeconds?: number }, @@ -141,21 +146,44 @@ export class KVCacheHandler implements CacheHandler { } } - // Check tag-based invalidation (parallel for lower latency) + // Check tag-based invalidation. + // Uses a local in-memory cache to avoid redundant KV reads for recently-seen tags. if (entry.tags.length > 0) { - const tagResults = await Promise.all( - entry.tags.map((tag) => this.kv.get(this.prefix + TAG_PREFIX + tag)), - ); - for (let i = 0; i < entry.tags.length; i++) { - const tagTime = tagResults[i]; - if (tagTime) { - const tagTimestamp = Number(tagTime); - if (Number.isNaN(tagTimestamp) || tagTimestamp >= entry.lastModified) { - // Tag was invalidated after this entry, or timestamp is corrupted - // — treat as miss to force re-render + const now = Date.now(); + const uncachedTags: string[] = []; + + // 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); + } + } + + // Second pass: fetch uncached tags from KV in parallel + if (uncachedTags.length > 0) { + const tagResults = await Promise.all( + uncachedTags.map((tag) => this.kv.get(this.prefix + TAG_PREFIX + tag)), + ); + for (let i = 0; i < uncachedTags.length; i++) { + const tagTime = tagResults[i]; + const tagTimestamp = tagTime ? Number(tagTime) : 0; + + // Populate local cache (0 for missing tags, NaN for corrupted) + this._tagCache.set(uncachedTags[i], { timestamp: tagTimestamp, fetchedAt: now }); + + if (tagTime) { + if (Number.isNaN(tagTimestamp) || tagTimestamp >= entry.lastModified) { + this._deleteInBackground(kvKey); + return null; + } + } } } } @@ -257,10 +285,15 @@ export class KVCacheHandler implements CacheHandler { }), ), ); + // Update local tag cache immediately so invalidations are reflected + // without waiting for the TTL to expire + for (const tag of validTags) { + this._tagCache.set(tag, { timestamp: now, fetchedAt: now }); + } } resetRequestCache(): void { - // No-op — KV is stateless per request + this._tagCache.clear(); } /** diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index 5b4461fb..d64e0565 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -457,6 +457,211 @@ describe("KVCacheHandler", () => { }); }); + // ------------------------------------------------------------------------- + // Local tag cache + // ------------------------------------------------------------------------- + + describe("local tag cache", () => { + it("cached tags skip KV on second get()", async () => { + const entryTime = 1000; + store.set( + "cache:tagged-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

hi

", pageData: {}, status: 200 }, + tags: ["t1", "t2"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + // No tag invalidation timestamps in KV — tags are valid + + // First get() — should fetch tags from KV (cache miss in local cache) + const result1 = await handler.get("tagged-page"); + expect(result1).not.toBeNull(); + + // kv.get calls: 1 for the entry + 2 for the tags = 3 + expect(kv.get).toHaveBeenCalledTimes(3); + + // Reset call counts + kv.get.mockClear(); + + // Second get() — tags should come from local cache, NOT from KV + const result2 = await handler.get("tagged-page"); + expect(result2).not.toBeNull(); + + // kv.get calls: 1 for the entry only, 0 for tags + expect(kv.get).toHaveBeenCalledTimes(1); + expect(kv.get).toHaveBeenCalledWith("cache:tagged-page"); + }); + + it("revalidateTag() updates local cache so subsequent get() skips KV for that tag", async () => { + const entryTime = 1000; + + // revalidateTag sets the invalidation timestamp + await handler.revalidateTag("t1"); + + kv.get.mockClear(); + + // Now store an entry with tag t1 that was created BEFORE the invalidation + store.set( + "cache:rt-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

old

", pageData: {}, status: 200 }, + tags: ["t1"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + + // get() should see tag t1 is invalidated via local cache — no KV GET for __tag:t1 + const result = await handler.get("rt-page"); + expect(result).toBeNull(); // invalidated + + // kv.get: 1 for entry, 0 for tags (t1 was in local cache) + expect(kv.get).toHaveBeenCalledTimes(1); + expect(kv.get).toHaveBeenCalledWith("cache:rt-page"); + }); + + it("TTL expiry triggers fresh KV fetch", async () => { + vi.useFakeTimers(); + const now = 10_000; + vi.setSystemTime(now); + + const entryTime = 1000; + store.set( + "cache:ttl-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

hi

", 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(); + }); + + it("tag invalidation works end-to-end with local cache", async () => { + const entryTime = 1000; + store.set( + "cache:e2e-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

original

", pageData: {}, status: 200 }, + tags: ["t1"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + + // First get() succeeds (no invalidation yet) + const result1 = await handler.get("e2e-page"); + expect(result1).not.toBeNull(); + + // Now invalidate tag t1 + await handler.revalidateTag("t1"); + + // get() should return null (cache miss due to tag invalidation) + const result2 = await handler.get("e2e-page"); + expect(result2).toBeNull(); + }); + + it("uncached tags are still fetched from KV", async () => { + const entryTime = 1000; + + // Store entry with two tags + store.set( + "cache:partial-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

hi

", pageData: {}, status: 200 }, + tags: ["t1", "t2"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + + // First get() populates local cache for both t1 and t2 + await handler.get("partial-page"); + kv.get.mockClear(); + + // Now add a DIFFERENT entry that shares t1 but also has t3 (not yet cached) + store.set( + "cache:partial-page2", + JSON.stringify({ + value: { kind: "PAGES", html: "

other

", pageData: {}, status: 200 }, + tags: ["t1", "t3"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + + const result = await handler.get("partial-page2"); + expect(result).not.toBeNull(); + + // kv.get: 1 for entry + 1 for t3 (t1 was cached). NOT 2 for tags. + expect(kv.get).toHaveBeenCalledTimes(2); + // Verify the calls are for the entry and t3 only + expect(kv.get).toHaveBeenCalledWith("cache:partial-page2"); + expect(kv.get).toHaveBeenCalledWith("__tag:t3"); + }); + + it("NaN tag timestamp in local cache treated as invalidation", async () => { + const entryTime = 1000; + + // Put a non-numeric tag value in KV + store.set("__tag:bad-tag", "not-a-number"); + + store.set( + "cache:nan-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

hi

", pageData: {}, status: 200 }, + tags: ["bad-tag"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + + // First get() — fetches from KV, gets NaN, caches it, returns null + const result1 = await handler.get("nan-page"); + expect(result1).toBeNull(); + + kv.get.mockClear(); + + // Re-store the entry (it was deleted by the first get) + store.set( + "cache:nan-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

hi

", pageData: {}, status: 200 }, + tags: ["bad-tag"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + + // Second get() — NaN is in local cache, should still treat as invalidation + const result2 = await handler.get("nan-page"); + expect(result2).toBeNull(); + + // kv.get: 1 for entry, 0 for tag (NaN was cached locally) + expect(kv.get).toHaveBeenCalledTimes(1); + }); + }); + // ------------------------------------------------------------------------- // STALE → regen → HIT lifecycle // From 94f07b7219e013a898734a90a055fbee0edae3dd Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 21:03:26 -0700 Subject: [PATCH 2/4] fix(test): guard fake timers with try/finally to prevent leaks Wrap vi.useFakeTimers() in try/finally so vi.useRealTimers() runs even if an assertion fails mid-test, preventing timer leaks into subsequent tests. --- tests/kv-cache-handler.test.ts | 62 ++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index d64e0565..8084a811 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -524,37 +524,39 @@ describe("KVCacheHandler", () => { it("TTL expiry triggers fresh KV fetch", async () => { vi.useFakeTimers(); - const now = 10_000; - vi.setSystemTime(now); - - const entryTime = 1000; - store.set( - "cache:ttl-page", - JSON.stringify({ - value: { kind: "PAGES", html: "

hi

", 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 + try { + const now = 10_000; + vi.setSystemTime(now); + + const entryTime = 1000; + store.set( + "cache:ttl-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

hi

", pageData: {}, status: 200 }, + tags: ["t1"], + lastModified: entryTime, + revalidateAt: null, + }), + ); - vi.useRealTimers(); + // 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(); + } }); it("tag invalidation works end-to-end with local cache", async () => { From 50694d62455ac4a48a6a912654d961098a9c7155 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 10:45:46 +0000 Subject: [PATCH 3/4] fix(kv): address bonk review comments on local tag cache - 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() --- .../vinext/src/cloudflare/kv-cache-handler.ts | 46 ++++++++-- tests/kv-cache-handler.test.ts | 90 ++++++++++++------- 2 files changed, 96 insertions(+), 40 deletions(-) diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index 42568335..fd388d9a 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -101,16 +101,23 @@ export class KVCacheHandler implements CacheHandler { /** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */ private _tagCache = new Map(); /** TTL (ms) for local tag cache entries. After this, re-fetch from KV. */ - private static TAG_CACHE_TTL = 5_000; + private _tagCacheTtl: number; constructor( kvNamespace: KVNamespace, - options?: { appPrefix?: string; ctx?: ExecutionContext; ttlSeconds?: number }, + options?: { + appPrefix?: string; + ctx?: ExecutionContext; + ttlSeconds?: number; + /** TTL in milliseconds for the local tag cache. Defaults to 5000ms. */ + tagCacheTtlMs?: number; + }, ) { this.kv = kvNamespace; this.prefix = options?.appPrefix ? `${options.appPrefix}:` : ""; this.ctx = options?.ctx; this.ttlSeconds = options?.ttlSeconds ?? 30 * 24 * 3600; + this._tagCacheTtl = options?.tagCacheTtlMs ?? 5_000; } async get(key: string, _ctx?: Record): Promise { @@ -152,33 +159,44 @@ export class KVCacheHandler implements CacheHandler { const now = Date.now(); const uncachedTags: string[] = []; - // First pass: check local cache for each tag + // First pass: check local cache for each tag. + // 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 < KVCacheHandler.TAG_CACHE_TTL) { + if (cached && now - cached.fetchedAt < this._tagCacheTtl) { // Local cache hit — check invalidation inline if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) { this._deleteInBackground(kvKey); return null; } } else { + // Expired or absent — evict stale entry and re-fetch from KV + if (cached) this._tagCache.delete(tag); uncachedTags.push(tag); } } - // Second pass: fetch uncached tags from KV in parallel + // Second pass: fetch uncached tags from KV in parallel. + // Populate the local cache for ALL fetched tags before checking invalidation, + // so that KV round-trips are not wasted when an earlier tag triggers an + // early return — subsequent get() calls benefit from the already-fetched results. if (uncachedTags.length > 0) { const tagResults = await Promise.all( uncachedTags.map((tag) => this.kv.get(this.prefix + TAG_PREFIX + tag)), ); + + // Populate cache for all results first for (let i = 0; i < uncachedTags.length; i++) { const tagTime = tagResults[i]; const tagTimestamp = tagTime ? Number(tagTime) : 0; - - // Populate local cache (0 for missing tags, NaN for corrupted) this._tagCache.set(uncachedTags[i], { timestamp: tagTimestamp, fetchedAt: now }); + } + // Then check for invalidation + for (let i = 0; i < uncachedTags.length; i++) { + const tagTime = tagResults[i]; if (tagTime) { + const tagTimestamp = Number(tagTime); if (Number.isNaN(tagTimestamp) || tagTimestamp >= entry.lastModified) { this._deleteInBackground(kvKey); return null; @@ -292,6 +310,20 @@ export class KVCacheHandler implements CacheHandler { } } + /** + * Clear the in-memory tag cache for this KVCacheHandler instance. + * + * Note: KVCacheHandler instances are typically reused across multiple + * requests in a Cloudflare Worker. The `_tagCache` is intentionally + * cross-request — it reduces redundant KV reads for recently-seen tags + * across all requests hitting the same isolate, bounded by `tagCacheTtlMs` + * (default 5s). vinext does NOT call this method per request. + * + * This is an opt-in escape hatch for callers that need stricter isolation + * (e.g., tests, or environments with custom lifecycle management). + * Callers that require per-request isolation should either construct a + * fresh KVCacheHandler per request or invoke this method explicitly. + */ resetRequestCache(): void { this._tagCache.clear(); } diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index 8084a811..c6df2e8e 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -523,40 +523,28 @@ describe("KVCacheHandler", () => { }); it("TTL expiry triggers fresh KV fetch", async () => { - vi.useFakeTimers(); - try { - const now = 10_000; - vi.setSystemTime(now); - - const entryTime = 1000; - store.set( - "cache:ttl-page", - JSON.stringify({ - value: { kind: "PAGES", html: "

hi

", pageData: {}, status: 200 }, - tags: ["t1"], - lastModified: entryTime, - revalidateAt: null, - }), - ); + // Use tagCacheTtlMs: 0 so entries expire immediately — no fake timers needed. + const shortTtlHandler = new KVCacheHandler(kv as any, { tagCacheTtlMs: 0 }); - // 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(); - } + const entryTime = 1000; + store.set( + "cache:ttl-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

hi

", pageData: {}, status: 200 }, + tags: ["t1"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + + // First get() — populates local tag cache (entry + tag = 2 calls) + await shortTtlHandler.get("ttl-page"); + expect(kv.get).toHaveBeenCalledTimes(2); + kv.get.mockClear(); + + // Second get() — TTL is 0ms so entry is already expired; must re-fetch tag from KV + await shortTtlHandler.get("ttl-page"); + expect(kv.get).toHaveBeenCalledTimes(2); // entry + tag again }); it("tag invalidation works end-to-end with local cache", async () => { @@ -662,6 +650,42 @@ describe("KVCacheHandler", () => { // kv.get: 1 for entry, 0 for tag (NaN was cached locally) expect(kv.get).toHaveBeenCalledTimes(1); }); + + it("resetRequestCache() forces tags to be re-fetched from KV", async () => { + const entryTime = 1000; + store.set( + "cache:reset-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

hi

", pageData: {}, status: 200 }, + tags: ["t1", "t2"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + + // First get() — populates local tag cache (1 entry + 2 tags = 3 calls) + const result1 = await handler.get("reset-page"); + expect(result1).not.toBeNull(); + expect(kv.get).toHaveBeenCalledTimes(3); + kv.get.mockClear(); + + // Second get() without reset — tags served from local cache (1 entry only) + const result2 = await handler.get("reset-page"); + expect(result2).not.toBeNull(); + expect(kv.get).toHaveBeenCalledTimes(1); + kv.get.mockClear(); + + // Clear the local cache + handler.resetRequestCache(); + + // Third get() after reset — tags must be re-fetched from KV (1 entry + 2 tags = 3 calls) + const result3 = await handler.get("reset-page"); + expect(result3).not.toBeNull(); + expect(kv.get).toHaveBeenCalledTimes(3); + expect(kv.get).toHaveBeenCalledWith("cache:reset-page"); + expect(kv.get).toHaveBeenCalledWith("__tag:t1"); + expect(kv.get).toHaveBeenCalledWith("__tag:t2"); + }); }); // ------------------------------------------------------------------------- From 6653e2960d6822831e7ce1e1f1b184ad57adc46d Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 10:50:29 +0000 Subject: [PATCH 4/4] refactor(kv): reuse cached timestamps in invalidation check loop 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. --- .../vinext/src/cloudflare/kv-cache-handler.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index c76c6fd0..78580148 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -174,19 +174,21 @@ export class KVCacheHandler implements CacheHandler { uncachedTags.map((tag) => this.kv.get(this.prefix + TAG_PREFIX + tag)), ); - // Populate cache for all results first + // Populate cache for all results first, then check for invalidation. + // Two-loop structure ensures all tag results are cached even when an + // earlier tag would cause an early return — so subsequent get() calls + // for entries sharing those tags don't redundantly re-fetch from KV. for (let i = 0; i < uncachedTags.length; i++) { const tagTime = tagResults[i]; const tagTimestamp = tagTime ? Number(tagTime) : 0; this._tagCache.set(uncachedTags[i], { timestamp: tagTimestamp, fetchedAt: now }); } - // Then check for invalidation - for (let i = 0; i < uncachedTags.length; i++) { - const tagTime = tagResults[i]; - if (tagTime) { - const tagTimestamp = Number(tagTime); - if (Number.isNaN(tagTimestamp) || tagTimestamp >= entry.lastModified) { + // Then check for invalidation using the now-cached timestamps + for (const tag of uncachedTags) { + const cached = this._tagCache.get(tag)!; + if (cached.timestamp !== 0) { + if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) { this._deleteInBackground(kvKey); return null; }