Skip to content

Commit 2650043

Browse files
authored
Sharded tag cache (#470)
* initial implementation of a sharded tag cache * use generateShardId * prettier * add an optional cache layer * added some tests * changeset * review fix * fix test message
1 parent acfc7f3 commit 2650043

File tree

12 files changed

+510
-9
lines changed

12 files changed

+510
-9
lines changed

.changeset/purple-penguins-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
feat: add a sharded SQLite Durable object implementation for the tag cache

examples/e2e/app-router/open-next.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { defineCloudflareConfig } from "@opennextjs/cloudflare";
22
import d1TagCache from "@opennextjs/cloudflare/d1-tag-cache";
33
import kvIncrementalCache from "@opennextjs/cloudflare/kv-cache";
44
import doQueue from "@opennextjs/cloudflare/durable-queue";
5+
import shardedTagCache from "@opennextjs/cloudflare/do-sharded-tag-cache";
56

67
export default defineCloudflareConfig({
78
incrementalCache: kvIncrementalCache,
8-
tagCache: d1TagCache,
9+
tagCache: shardedTagCache({ numberOfShards: 12, regionalCache: true }),
910
queue: doQueue,
1011
});

examples/e2e/app-router/wrangler.jsonc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@
1313
{
1414
"name": "NEXT_CACHE_REVALIDATION_DURABLE_OBJECT",
1515
"class_name": "DurableObjectQueueHandler"
16+
},
17+
{
18+
"name": "NEXT_CACHE_D1_SHARDED",
19+
"class_name": "DOShardedTagCache"
1620
}
1721
]
1822
},
1923
"migrations": [
2024
{
2125
"tag": "v1",
22-
"new_sqlite_classes": ["DurableObjectQueueHandler"]
26+
"new_sqlite_classes": ["DurableObjectQueueHandler", "DOShardedTagCache"]
2327
}
2428
],
2529
"kv_namespaces": [

packages/cloudflare/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
"dependencies": {
7474
"@ast-grep/napi": "^0.36.1",
7575
"@dotenvx/dotenvx": "catalog:",
76-
"@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@773",
76+
"@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@778",
7777
"enquirer": "^2.4.1",
7878
"glob": "catalog:",
7979
"yaml": "^2.7.0"

packages/cloudflare/src/api/cloudflare-context.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Context, RunningCodeOptions } from "node:vm";
22

33
import type { DurableObjectQueueHandler } from "./durable-objects/queue";
4+
import { DOShardedTagCache } from "./durable-objects/sharded-tag-cache";
45

56
declare global {
67
interface CloudflareEnv {
@@ -16,6 +17,9 @@ declare global {
1617
NEXT_CACHE_REVALIDATION_WORKER?: Service;
1718
// Durable Object namespace to use for the durable object queue handler
1819
NEXT_CACHE_REVALIDATION_DURABLE_OBJECT?: DurableObjectNamespace<DurableObjectQueueHandler>;
20+
// Durables object namespace to use for the sharded tag cache
21+
NEXT_CACHE_D1_SHARDED?: DurableObjectNamespace<DOShardedTagCache>;
22+
1923
// Asset binding
2024
ASSETS?: Fetcher;
2125

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
import doShardedTagCache from "./do-sharded-tag-cache";
4+
5+
const hasBeenRevalidatedMock = vi.fn();
6+
const writeTagsMock = vi.fn();
7+
const idFromNameMock = vi.fn();
8+
const getMock = vi
9+
.fn()
10+
.mockReturnValue({ hasBeenRevalidated: hasBeenRevalidatedMock, writeTags: writeTagsMock });
11+
const waitUntilMock = vi.fn().mockImplementation(async (fn) => fn());
12+
vi.mock("./cloudflare-context", () => ({
13+
getCloudflareContext: () => ({
14+
env: { NEXT_CACHE_D1_SHARDED: { idFromName: idFromNameMock, get: getMock } },
15+
ctx: { waitUntil: waitUntilMock },
16+
}),
17+
}));
18+
19+
describe("DOShardedTagCache", () => {
20+
afterEach(() => vi.clearAllMocks());
21+
22+
describe("generateShardId", () => {
23+
it("should generate a shardId", () => {
24+
const cache = doShardedTagCache();
25+
const expectedResult = new Map();
26+
expectedResult.set("shard-1", ["tag1"]);
27+
expectedResult.set("shard-2", ["tag2"]);
28+
expect(cache.generateShards(["tag1", "tag2"])).toEqual(expectedResult);
29+
});
30+
31+
it("should group tags by shard", () => {
32+
const cache = doShardedTagCache();
33+
const expectedResult = new Map();
34+
expectedResult.set("shard-1", ["tag1", "tag6"]);
35+
expect(cache.generateShards(["tag1", "tag6"])).toEqual(expectedResult);
36+
});
37+
38+
it("should generate the same shardId for the same tag", () => {
39+
const cache = doShardedTagCache();
40+
const firstResult = cache.generateShards(["tag1"]);
41+
const secondResult = cache.generateShards(["tag1", "tag3", "tag4"]);
42+
expect(firstResult.get("shard-1")).toEqual(secondResult.get("shard-1"));
43+
});
44+
});
45+
46+
describe("hasBeenRevalidated", () => {
47+
beforeEach(() => {
48+
globalThis.openNextConfig = {
49+
dangerous: { disableTagCache: false },
50+
};
51+
});
52+
it("should return false if the cache is disabled", async () => {
53+
globalThis.openNextConfig = {
54+
dangerous: { disableTagCache: true },
55+
};
56+
const cache = doShardedTagCache();
57+
const result = await cache.hasBeenRevalidated(["tag1"]);
58+
expect(result).toBe(false);
59+
expect(idFromNameMock).not.toHaveBeenCalled();
60+
});
61+
62+
it("should return false if stub return false", async () => {
63+
const cache = doShardedTagCache();
64+
cache.getFromRegionalCache = vi.fn();
65+
hasBeenRevalidatedMock.mockImplementationOnce(() => false);
66+
const result = await cache.hasBeenRevalidated(["tag1"], 123456);
67+
expect(cache.getFromRegionalCache).toHaveBeenCalled();
68+
expect(idFromNameMock).toHaveBeenCalled();
69+
expect(hasBeenRevalidatedMock).toHaveBeenCalled();
70+
expect(result).toBe(false);
71+
});
72+
73+
it("should return true if stub return true", async () => {
74+
const cache = doShardedTagCache();
75+
cache.getFromRegionalCache = vi.fn();
76+
hasBeenRevalidatedMock.mockImplementationOnce(() => true);
77+
const result = await cache.hasBeenRevalidated(["tag1"], 123456);
78+
expect(cache.getFromRegionalCache).toHaveBeenCalled();
79+
expect(idFromNameMock).toHaveBeenCalled();
80+
expect(hasBeenRevalidatedMock).toHaveBeenCalledWith(["tag1"], 123456);
81+
expect(result).toBe(true);
82+
});
83+
84+
it("should return false if it throws", async () => {
85+
const cache = doShardedTagCache();
86+
cache.getFromRegionalCache = vi.fn();
87+
hasBeenRevalidatedMock.mockImplementationOnce(() => {
88+
throw new Error("error");
89+
});
90+
const result = await cache.hasBeenRevalidated(["tag1"], 123456);
91+
expect(cache.getFromRegionalCache).toHaveBeenCalled();
92+
expect(idFromNameMock).toHaveBeenCalled();
93+
expect(hasBeenRevalidatedMock).toHaveBeenCalled();
94+
expect(result).toBe(false);
95+
});
96+
97+
it("Should return from the cache if it was found there", async () => {
98+
const cache = doShardedTagCache();
99+
cache.getFromRegionalCache = vi.fn().mockReturnValueOnce(new Response("true"));
100+
const result = await cache.hasBeenRevalidated(["tag1"], 123456);
101+
expect(result).toBe(true);
102+
expect(idFromNameMock).not.toHaveBeenCalled();
103+
expect(hasBeenRevalidatedMock).not.toHaveBeenCalled();
104+
});
105+
106+
it("should try to put the result in the cache if it was not revalidated", async () => {
107+
const cache = doShardedTagCache();
108+
cache.getFromRegionalCache = vi.fn();
109+
cache.putToRegionalCache = vi.fn();
110+
hasBeenRevalidatedMock.mockImplementationOnce(() => false);
111+
const result = await cache.hasBeenRevalidated(["tag1"], 123456);
112+
expect(result).toBe(false);
113+
114+
expect(waitUntilMock).toHaveBeenCalled();
115+
expect(cache.putToRegionalCache).toHaveBeenCalled();
116+
});
117+
118+
it("should call all the shards", async () => {
119+
const cache = doShardedTagCache();
120+
cache.getFromRegionalCache = vi.fn();
121+
const result = await cache.hasBeenRevalidated(["tag1", "tag2"], 123456);
122+
expect(result).toBe(false);
123+
expect(idFromNameMock).toHaveBeenCalledTimes(2);
124+
expect(hasBeenRevalidatedMock).toHaveBeenCalledTimes(2);
125+
});
126+
});
127+
128+
describe("writeTags", () => {
129+
beforeEach(() => {
130+
globalThis.openNextConfig = {
131+
dangerous: { disableTagCache: false },
132+
};
133+
});
134+
it("should return early if the cache is disabled", async () => {
135+
globalThis.openNextConfig = {
136+
dangerous: { disableTagCache: true },
137+
};
138+
const cache = doShardedTagCache();
139+
await cache.writeTags(["tag1"]);
140+
expect(idFromNameMock).not.toHaveBeenCalled();
141+
expect(writeTagsMock).not.toHaveBeenCalled();
142+
});
143+
144+
it("should write the tags to the cache", async () => {
145+
const cache = doShardedTagCache();
146+
await cache.writeTags(["tag1"]);
147+
expect(idFromNameMock).toHaveBeenCalled();
148+
expect(writeTagsMock).toHaveBeenCalled();
149+
expect(writeTagsMock).toHaveBeenCalledWith(["tag1"]);
150+
});
151+
152+
it("should write the tags to the cache for multiple shards", async () => {
153+
const cache = doShardedTagCache();
154+
await cache.writeTags(["tag1", "tag2"]);
155+
expect(idFromNameMock).toHaveBeenCalledTimes(2);
156+
expect(writeTagsMock).toHaveBeenCalledTimes(2);
157+
expect(writeTagsMock).toHaveBeenCalledWith(["tag1"]);
158+
expect(writeTagsMock).toHaveBeenCalledWith(["tag2"]);
159+
});
160+
161+
it("should call deleteRegionalCache", async () => {
162+
const cache = doShardedTagCache();
163+
cache.deleteRegionalCache = vi.fn();
164+
await cache.writeTags(["tag1"]);
165+
expect(cache.deleteRegionalCache).toHaveBeenCalled();
166+
expect(cache.deleteRegionalCache).toHaveBeenCalledWith("shard-1", ["tag1"]);
167+
});
168+
});
169+
170+
describe("getCacheInstance", () => {
171+
it("should return undefined by default", async () => {
172+
const cache = doShardedTagCache();
173+
expect(await cache.getCacheInstance()).toBeUndefined();
174+
});
175+
176+
it("should try to return the cache instance if regional cache is enabled", async () => {
177+
// @ts-expect-error - Defined on cloudfare context
178+
globalThis.caches = {
179+
open: vi.fn().mockResolvedValue("cache"),
180+
};
181+
const cache = doShardedTagCache({ numberOfShards: 4, regionalCache: true });
182+
expect(cache.localCache).toBeUndefined();
183+
expect(await cache.getCacheInstance()).toBe("cache");
184+
expect(cache.localCache).toBe("cache");
185+
// @ts-expect-error - Defined on cloudfare context
186+
globalThis.caches = undefined;
187+
});
188+
});
189+
190+
describe("getFromRegionalCache", () => {
191+
it("should return undefined if regional cache is disabled", async () => {
192+
const cache = doShardedTagCache();
193+
expect(await cache.getFromRegionalCache("shard-1", ["tag1"])).toBeUndefined();
194+
});
195+
196+
it("should call .match on the cache", async () => {
197+
// @ts-expect-error - Defined on cloudfare context
198+
globalThis.caches = {
199+
open: vi.fn().mockResolvedValue({
200+
match: vi.fn().mockResolvedValue("response"),
201+
}),
202+
};
203+
const cache = doShardedTagCache({ numberOfShards: 4, regionalCache: true });
204+
expect(await cache.getFromRegionalCache("shard-1", ["tag1"])).toBe("response");
205+
// @ts-expect-error - Defined on cloudfare context
206+
globalThis.caches = undefined;
207+
});
208+
});
209+
});

0 commit comments

Comments
 (0)