From af2b4301e929470418bcd2ea77aca26f353f4b30 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 28 Jul 2025 13:14:40 +0200 Subject: [PATCH 1/3] add an option to persist missing tags --- examples/e2e/app-router/open-next.config.ts | 2 + .../tag-cache/do-sharded-tag-cache.spec.ts | 166 +++++++++++++++++- .../tag-cache/do-sharded-tag-cache.ts | 19 +- 3 files changed, 184 insertions(+), 3 deletions(-) diff --git a/examples/e2e/app-router/open-next.config.ts b/examples/e2e/app-router/open-next.config.ts index 40326044..2b25f730 100644 --- a/examples/e2e/app-router/open-next.config.ts +++ b/examples/e2e/app-router/open-next.config.ts @@ -10,6 +10,8 @@ export default defineCloudflareConfig({ // With such a configuration, we could have up to 12 * (8 + 2) = 120 Durable Objects instances tagCache: shardedTagCache({ baseShardSize: 12, + regionalCache: true, + regionalCacheDangerouslyPersistMissingTags: true, shardReplication: { numberOfSoftReplicas: 8, numberOfHardReplicas: 2, diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts index 8632f9b4..1b83a594 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts @@ -5,9 +5,14 @@ import shardedDOTagCache, { AVAILABLE_REGIONS, DOId } from "./do-sharded-tag-cac const hasBeenRevalidatedMock = vi.fn(); const writeTagsMock = vi.fn(); const idFromNameMock = vi.fn(); +const getRevalidationTimesMock = vi.fn(); const getMock = vi .fn() - .mockReturnValue({ hasBeenRevalidated: hasBeenRevalidatedMock, writeTags: writeTagsMock }); + .mockReturnValue({ + hasBeenRevalidated: hasBeenRevalidatedMock, + writeTags: writeTagsMock, + getRevalidationTimes: getRevalidationTimesMock, + }); const waitUntilMock = vi.fn().mockImplementation(async (fn) => fn()); globalThis.continent = undefined; const sendDLQMock = vi.fn(); @@ -391,6 +396,165 @@ describe("DOShardedTagCache", () => { }); }); + describe("putToRegionalCache", () => { + it("should return early if regional cache is disabled", async () => { + const cache = shardedDOTagCache(); + const doId = new DOId({ + baseShardId: "shard-1", + numberOfReplicas: 1, + shardType: "hard", + }); + await cache.putToRegionalCache({ doId, tags: ["tag1"] }, getMock()); + expect(getRevalidationTimesMock).not.toHaveBeenCalled(); + }); + + it("should put the tags in the regional cache if the tags exists in the DO", async () => { + const putMock = vi.fn(); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = { + open: vi.fn().mockResolvedValue({ + put: putMock, + }), + }; + const cache = shardedDOTagCache({ baseShardSize: 4, regionalCache: true }); + const doId = new DOId({ + baseShardId: "shard-1", + numberOfReplicas: 1, + shardType: "hard", + }); + + getRevalidationTimesMock.mockResolvedValueOnce({ tag1: 123456 }); + + await cache.putToRegionalCache({ doId, tags: ["tag1"] }, getMock()); + + expect(getRevalidationTimesMock).toHaveBeenCalledWith(["tag1"]); + expect(putMock).toHaveBeenCalledWith( + "http://local.cache/shard/tag-hard;shard-1?tag=tag1", + expect.any(Response) + ); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = undefined; + }); + + it("should not put the tags in the regional cache if the tags does not exists in the DO", async () => { + const putMock = vi.fn(); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = { + open: vi.fn().mockResolvedValue({ + put: putMock, + }), + }; + const cache = shardedDOTagCache({ baseShardSize: 4, regionalCache: true }); + const doId = new DOId({ + baseShardId: "shard-1", + numberOfReplicas: 1, + shardType: "hard", + }); + + getRevalidationTimesMock.mockResolvedValueOnce({}); + + await cache.putToRegionalCache({ doId, tags: ["tag1"] }, getMock()); + + expect(getRevalidationTimesMock).toHaveBeenCalledWith(["tag1"]); + expect(putMock).not.toHaveBeenCalled(); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = undefined; + }); + + it("should put multiple tags in the regional cache", async () => { + const putMock = vi.fn(); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = { + open: vi.fn().mockResolvedValue({ + put: putMock, + }), + }; + const cache = shardedDOTagCache({ baseShardSize: 4, regionalCache: true }); + const doId = new DOId({ + baseShardId: "shard-1", + numberOfReplicas: 1, + shardType: "hard", + }); + + getRevalidationTimesMock.mockResolvedValueOnce({ tag1: 123456, tag2: 654321 }); + + await cache.putToRegionalCache({ doId, tags: ["tag1", "tag2"] }, getMock()); + + expect(getRevalidationTimesMock).toHaveBeenCalledWith(["tag1", "tag2"]); + expect(putMock).toHaveBeenCalledWith( + "http://local.cache/shard/tag-hard;shard-1?tag=tag1", + expect.any(Response) + ); + expect(putMock).toHaveBeenCalledWith( + "http://local.cache/shard/tag-hard;shard-1?tag=tag2", + expect.any(Response) + ); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = undefined; + }); + + it("should put missing tag in the regional cache if `regionalCacheDangerouslyPersistMissingTags` is true", async () => { + const putMock = vi.fn(); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = { + open: vi.fn().mockResolvedValue({ + put: putMock, + }), + }; + const cache = shardedDOTagCache({ + baseShardSize: 4, + regionalCache: true, + regionalCacheDangerouslyPersistMissingTags: true, + }); + const doId = new DOId({ + baseShardId: "shard-1", + numberOfReplicas: 1, + shardType: "hard", + }); + + getRevalidationTimesMock.mockResolvedValueOnce({}); + + await cache.putToRegionalCache({ doId, tags: ["tag1"] }, getMock()); + + expect(getRevalidationTimesMock).toHaveBeenCalledWith(["tag1"]); + expect(putMock).toHaveBeenCalledWith( + "http://local.cache/shard/tag-hard;shard-1?tag=tag1", + expect.any(Response) + ); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = undefined; + }); + + it("should not put missing tag in the regional cache if `regionalCacheDangerouslyPersistMissingTags` is false", async () => { + const putMock = vi.fn(); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = { + open: vi.fn().mockResolvedValue({ + put: putMock, + }), + }; + const cache = shardedDOTagCache({ + baseShardSize: 4, + regionalCache: true, + regionalCacheDangerouslyPersistMissingTags: false, + }); + const doId = new DOId({ + baseShardId: "shard-1", + numberOfReplicas: 1, + shardType: "hard", + }); + + getRevalidationTimesMock.mockResolvedValueOnce({}); + + await cache.putToRegionalCache({ doId, tags: ["tag1"] }, getMock()); + + expect(getRevalidationTimesMock).toHaveBeenCalledWith(["tag1"]); + expect(putMock).not.toHaveBeenCalled(); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = undefined; + }); + }); + describe("getCacheKey", () => { it("should return the cache key without the random part", async () => { const cache = shardedDOTagCache(); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts index 292f0c6c..20d2dfcf 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts @@ -49,6 +49,14 @@ interface ShardedDOTagCacheOptions { */ regionalCacheTtlSec?: number; + /** + * Whether to persist missing tags in the regional cache. + * This is dangerous if you don't invalidate the Cache API when you revalidate tags as you could end up storing stale data in the data cache. + * + * @default false + */ + regionalCacheDangerouslyPersistMissingTags?: boolean; + /** * Enable shard replication to handle higher load. * @@ -465,8 +473,15 @@ class ShardedDOTagCache implements NextModeTagCache { const tagsLastRevalidated = await stub.getRevalidationTimes(tags); await Promise.all( tags.map(async (tag) => { - const lastRevalidated = tagsLastRevalidated[tag]; - if (lastRevalidated === undefined) return; // Should we store something in the cache if the tag is not found ? + let lastRevalidated = tagsLastRevalidated[tag]; + if (lastRevalidated === undefined) { + if (this.opts.regionalCacheDangerouslyPersistMissingTags) { + lastRevalidated = 0; // If the tag is not found, we set it to 0 as it means it has never been revalidated before. + } else { + debugCache("Tag not found in revalidation times", { tag, optsKey }); + return; // If the tag is not found, we skip it + } + } const cacheKey = this.getCacheUrlKey(optsKey.doId, tag); debugCache("Putting to regional cache", { cacheKey, lastRevalidated }); await cache.put( From 83bbdba9bda31aa93a584ab54455786e6562a774 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 28 Jul 2025 13:15:29 +0200 Subject: [PATCH 2/3] Changeset --- .changeset/loud-mice-know.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/loud-mice-know.md diff --git a/.changeset/loud-mice-know.md b/.changeset/loud-mice-know.md new file mode 100644 index 00000000..f00c2e2b --- /dev/null +++ b/.changeset/loud-mice-know.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Add an option to persist missing tags in the regional tag cache From 645314e4a7d6d6372e9f1b3d5fe4743e8223932f Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 28 Jul 2025 13:47:16 +0200 Subject: [PATCH 3/3] prettier fix --- .../overrides/tag-cache/do-sharded-tag-cache.spec.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts index 1b83a594..4f52dd15 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts @@ -6,13 +6,11 @@ const hasBeenRevalidatedMock = vi.fn(); const writeTagsMock = vi.fn(); const idFromNameMock = vi.fn(); const getRevalidationTimesMock = vi.fn(); -const getMock = vi - .fn() - .mockReturnValue({ - hasBeenRevalidated: hasBeenRevalidatedMock, - writeTags: writeTagsMock, - getRevalidationTimes: getRevalidationTimesMock, - }); +const getMock = vi.fn().mockReturnValue({ + hasBeenRevalidated: hasBeenRevalidatedMock, + writeTags: writeTagsMock, + getRevalidationTimes: getRevalidationTimesMock, +}); const waitUntilMock = vi.fn().mockImplementation(async (fn) => fn()); globalThis.continent = undefined; const sendDLQMock = vi.fn();