diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index 9141e2b8..78580148 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -87,14 +87,26 @@ export class KVCacheHandler implements CacheHandler { private ctx: ExecutionContextLike | 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 _tagCacheTtl: number; + constructor( kvNamespace: KVNamespace, - options?: { appPrefix?: string; ctx?: ExecutionContextLike; ttlSeconds?: number }, + options?: { + appPrefix?: string; + ctx?: ExecutionContextLike; + 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 { @@ -130,21 +142,57 @@ 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. + // 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) { + // 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. + // 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, 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 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; + } + } } } } @@ -246,10 +294,29 @@ 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 }); + } } + /** + * 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 { - // 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 b66a8325..99bb4180 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -521,6 +521,237 @@ 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 () => { + // Use tagCacheTtlMs: 0 so entries expire immediately — no fake timers needed. + const shortTtlHandler = new KVCacheHandler(kv as any, { tagCacheTtlMs: 0 }); + + 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 () => { + 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); + }); + + 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"); + }); + }); + // ------------------------------------------------------------------------- // STALE → regen → HIT lifecycle //