Skip to content

Commit ff2dd55

Browse files
james-elicxvicb
andauthored
feat: d1 adapter for the tag cache (#320)
* fix: enable using the `direct` queue for isr * skip the ssr fetch cache test due to flakiness * remove redundant import * fix: enable using the `direct` queue for isr * skip the ssr fetch cache test due to flakiness * feat: d1 adapter for the tag cache * re-use aws manifest output to create our manifest * address review comments * use results instead of mapping over * output an sql file instead * move file inside a cloudflare directory * use a single insert statement * move the d1 setup to preview and e2e so you can still use skipbuild * use two tables for the tag cache * json.stringify * move where the json.stringify is being done * insert unique tags only, and use recoverableerror * re-use the manifest from the createcacheassets function * re-use the useTagCache var from aws * fix flaky test * change type import location * Revert "move where the json.stringify is being done" * rename to tables * add back comment that the revert wiped out * rebuild lockfile * Update packages/cloudflare/src/api/d1-tag-cache.ts Co-authored-by: Victor Berchet <victor@suumit.com> * Update packages/cloudflare/src/api/d1-tag-cache.ts * Update packages/cloudflare/src/api/d1-tag-cache.ts --------- Co-authored-by: Victor Berchet <victor@suumit.com>
1 parent 042bdd7 commit ff2dd55

File tree

15 files changed

+377
-217
lines changed

15 files changed

+377
-217
lines changed

.changeset/five-balloons-walk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": minor
3+
---
4+
5+
feat: d1 adapter for the tag cache

examples/e2e/app-router/e2e/after.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { expect, test } from "@playwright/test";
22

3-
// Cache is currently not supported: https://github.com/opennextjs/opennextjs-cloudflare/issues/105
4-
// (Note: specifically this test relied on `unstable_cache`: https://github.com/opennextjs/opennextjs-cloudflare/issues/105#issuecomment-2627074820)
5-
test.skip("Next after", async ({ request }) => {
3+
test("Next after", async ({ request }) => {
64
const initialSSG = await request.get("/api/after/ssg");
75
expect(initialSSG.status()).toEqual(200);
86
const initialSSGJson = await initialSSG.json();

examples/e2e/app-router/e2e/revalidateTag.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { expect, test } from "@playwright/test";
22

3-
// Cache (and revalidateTag) is currently not supported: https://github.com/opennextjs/opennextjs-cloudflare/issues/105
4-
test.skip("Revalidate tag", async ({ page, request }) => {
3+
test("Revalidate tag", async ({ page, request }) => {
54
test.setTimeout(45000);
65
// We need to hit the page twice to make sure it's properly cached
76
// Turbo might cache next build result, resulting in the tag being newer than the page
@@ -69,8 +68,7 @@ test.skip("Revalidate tag", async ({ page, request }) => {
6968
expect(nextCacheHeaderNested).toEqual("HIT");
7069
});
7170

72-
// Cache (and revalidatePath) is currently not supported: https://github.com/opennextjs/opennextjs-cloudflare/issues/105
73-
test.skip("Revalidate path", async ({ page, request }) => {
71+
test("Revalidate path", async ({ page, request }) => {
7472
await page.goto("/revalidate-path");
7573

7674
let elLayout = page.getByText("RequestID:");

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
2-
import cache from "@opennextjs/cloudflare/kv-cache";
2+
import tagCache from "@opennextjs/cloudflare/d1-tag-cache";
3+
import incrementalCache from "@opennextjs/cloudflare/kv-cache";
34
import memoryQueue from "@opennextjs/cloudflare/memory-queue";
45

56
const config: OpenNextConfig = {
67
default: {
78
override: {
89
wrapper: "cloudflare-node",
910
converter: "edge",
10-
incrementalCache: async () => cache,
11+
incrementalCache: async () => incrementalCache,
12+
tagCache: () => tagCache,
1113
queue: () => memoryQueue,
12-
// Unused implementation
13-
tagCache: "dummy",
1414
},
1515
},
1616

examples/e2e/app-router/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
"start": "next start --port 3001",
1010
"lint": "next lint",
1111
"clean": "rm -rf .turbo node_modules .next .open-next",
12+
"d1:clean": "wrangler d1 execute NEXT_CACHE_D1 --command \"DROP TABLE IF EXISTS tags; DROP TABLE IF EXISTS revalidations\"",
13+
"d1:setup": "wrangler d1 execute NEXT_CACHE_D1 --file .open-next/cloudflare/cache-assets-manifest.sql",
1214
"build:worker": "pnpm opennextjs-cloudflare",
13-
"preview": "pnpm build:worker && pnpm wrangler dev",
14-
"e2e": "playwright test -c e2e/playwright.config.ts"
15+
"preview": "pnpm build:worker && pnpm d1:clean && pnpm d1:setup && pnpm wrangler dev",
16+
"e2e": "pnpm d1:clean && pnpm d1:setup && playwright test -c e2e/playwright.config.ts"
1517
},
1618
"dependencies": {
1719
"@opennextjs/cloudflare": "workspace:*",

examples/e2e/app-router/wrangler.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,12 @@
1313
"binding": "NEXT_CACHE_WORKERS_KV",
1414
"id": "<BINDING_ID>"
1515
}
16+
],
17+
"d1_databases": [
18+
{
19+
"binding": "NEXT_CACHE_D1",
20+
"database_id": "NEXT_CACHE_D1",
21+
"database_name": "NEXT_CACHE_D1"
22+
}
1623
]
1724
}

packages/cloudflare/env.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ declare global {
77
NEXT_PRIVATE_DEBUG_CACHE?: string;
88
OPEN_NEXT_ORIGIN: string;
99
NODE_ENV?: string;
10+
NEXT_CACHE_D1_TAGS_TABLE?: string;
11+
NEXT_CACHE_D1_REVALIDATIONS_TABLE?: string;
1012
}
1113
}
1214
}

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.34.1",
7575
"@dotenvx/dotenvx": "catalog:",
76-
"@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@733",
76+
"@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@748",
7777
"enquirer": "^2.4.1",
7878
"glob": "catalog:",
7979
"yaml": "^2.7.0"

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import type { Context, RunningCodeOptions } from "node:vm";
33
declare global {
44
interface CloudflareEnv {
55
NEXT_CACHE_WORKERS_KV?: KVNamespace;
6+
NEXT_CACHE_D1?: D1Database;
7+
NEXT_CACHE_D1_TAGS_TABLE?: string;
8+
NEXT_CACHE_D1_REVALIDATIONS_TABLE?: string;
69
ASSETS?: Fetcher;
710
}
811
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
2+
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
3+
import type { TagCache } from "@opennextjs/aws/types/overrides.js";
4+
import { RecoverableError } from "@opennextjs/aws/utils/error.js";
5+
6+
import { getCloudflareContext } from "./cloudflare-context.js";
7+
8+
/**
9+
* An instance of the Tag Cache that uses a D1 binding (`NEXT_CACHE_D1`) as it's underlying data store.
10+
*
11+
* **Tag/path mappings table**
12+
*
13+
* Information about the relation between tags and paths is stored in a `tags` table that contains
14+
* two columns; `tag`, and `path`. The table name can be configured with `NEXT_CACHE_D1_TAGS_TABLE`
15+
* environment variable.
16+
*
17+
* This table should be populated using an SQL file that is generated during the build process.
18+
*
19+
* **Tag revalidations table**
20+
*
21+
* Revalidation times for tags are stored in a `revalidations` table that contains two columns; `tags`,
22+
* and `revalidatedAt`. The table name can be configured with `NEXT_CACHE_D1_REVALIDATIONS_TABLE`
23+
* environment variable.
24+
*/
25+
class D1TagCache implements TagCache {
26+
public readonly name = "d1-tag-cache";
27+
28+
public async getByPath(rawPath: string): Promise<string[]> {
29+
const { isDisabled, db, tables } = this.getConfig();
30+
if (isDisabled) return [];
31+
32+
const path = this.getCacheKey(rawPath);
33+
34+
try {
35+
const { success, results } = await db
36+
.prepare(`SELECT tag FROM ${JSON.stringify(tables.tags)} WHERE path = ?`)
37+
.bind(path)
38+
.all<{ tag: string }>();
39+
40+
if (!success) throw new RecoverableError(`D1 select failed for ${path}`);
41+
42+
const tags = results?.map((item) => this.removeBuildId(item.tag));
43+
44+
debug("tags for path", path, tags);
45+
return tags;
46+
} catch (e) {
47+
error("Failed to get tags by path", e);
48+
return [];
49+
}
50+
}
51+
52+
public async getByTag(rawTag: string): Promise<string[]> {
53+
const { isDisabled, db, tables } = this.getConfig();
54+
if (isDisabled) return [];
55+
56+
const tag = this.getCacheKey(rawTag);
57+
58+
try {
59+
const { success, results } = await db
60+
.prepare(`SELECT path FROM ${JSON.stringify(tables.tags)} WHERE tag = ?`)
61+
.bind(tag)
62+
.all<{ path: string }>();
63+
64+
if (!success) throw new RecoverableError(`D1 select failed for ${tag}`);
65+
66+
const paths = results?.map((item) => this.removeBuildId(item.path));
67+
68+
debug("paths for tag", tag, paths);
69+
return paths;
70+
} catch (e) {
71+
error("Failed to get by tag", e);
72+
return [];
73+
}
74+
}
75+
76+
public async getLastModified(path: string, lastModified?: number): Promise<number> {
77+
const { isDisabled, db, tables } = this.getConfig();
78+
if (isDisabled) return lastModified ?? Date.now();
79+
80+
try {
81+
const { success, results } = await db
82+
.prepare(
83+
`SELECT ${JSON.stringify(tables.revalidations)}.tag FROM ${JSON.stringify(tables.revalidations)}
84+
INNER JOIN ${JSON.stringify(tables.tags)} ON ${JSON.stringify(tables.revalidations)}.tag = ${JSON.stringify(tables.tags)}.tag
85+
WHERE ${JSON.stringify(tables.tags)}.path = ? AND ${JSON.stringify(tables.revalidations)}.revalidatedAt > ?;`
86+
)
87+
.bind(this.getCacheKey(path), lastModified ?? 0)
88+
.all<{ tag: string }>();
89+
90+
if (!success) throw new RecoverableError(`D1 select failed for ${path} - ${lastModified ?? 0}`);
91+
92+
debug("revalidatedTags", results);
93+
return results?.length > 0 ? -1 : (lastModified ?? Date.now());
94+
} catch (e) {
95+
error("Failed to get revalidated tags", e);
96+
return lastModified ?? Date.now();
97+
}
98+
}
99+
100+
public async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]): Promise<void> {
101+
const { isDisabled, db, tables } = this.getConfig();
102+
if (isDisabled || tags.length === 0) return;
103+
104+
try {
105+
const uniqueTags = new Set<string>();
106+
const results = await db.batch(
107+
tags
108+
.map(({ tag, path, revalidatedAt }) => {
109+
if (revalidatedAt === 1) {
110+
// new tag/path mapping from set
111+
return db
112+
.prepare(`INSERT INTO ${JSON.stringify(tables.tags)} (tag, path) VALUES (?, ?)`)
113+
.bind(this.getCacheKey(tag), this.getCacheKey(path));
114+
}
115+
116+
if (!uniqueTags.has(tag) && revalidatedAt !== -1) {
117+
// tag was revalidated
118+
uniqueTags.add(tag);
119+
return db
120+
.prepare(
121+
`INSERT INTO ${JSON.stringify(tables.revalidations)} (tag, revalidatedAt) VALUES (?, ?)`
122+
)
123+
.bind(this.getCacheKey(tag), revalidatedAt ?? Date.now());
124+
}
125+
})
126+
.filter((stmt) => !!stmt)
127+
);
128+
129+
const failedResults = results.filter((res) => !res.success);
130+
131+
if (failedResults.length > 0) {
132+
throw new RecoverableError(`${failedResults.length} tags failed to write`);
133+
}
134+
} catch (e) {
135+
error("Failed to batch write tags", e);
136+
}
137+
}
138+
139+
private getConfig() {
140+
const cfEnv = getCloudflareContext().env;
141+
const db = cfEnv.NEXT_CACHE_D1;
142+
143+
if (!db) debug("No D1 database found");
144+
145+
const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig
146+
.dangerous?.disableTagCache;
147+
148+
if (!db || isDisabled) {
149+
return { isDisabled: true as const };
150+
}
151+
152+
return {
153+
isDisabled: false as const,
154+
db,
155+
tables: {
156+
tags: cfEnv.NEXT_CACHE_D1_TAGS_TABLE ?? "tags",
157+
revalidations: cfEnv.NEXT_CACHE_D1_REVALIDATIONS_TABLE ?? "revalidations",
158+
},
159+
};
160+
}
161+
162+
protected removeBuildId(key: string) {
163+
return key.replace(`${this.getBuildId()}/`, "");
164+
}
165+
166+
protected getCacheKey(key: string) {
167+
return `${this.getBuildId()}/${key}`.replaceAll("//", "/");
168+
}
169+
170+
protected getBuildId() {
171+
return process.env.NEXT_BUILD_ID ?? "no-build-id";
172+
}
173+
}
174+
175+
export default new D1TagCache();

packages/cloudflare/src/cli/build/build.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import logger from "@opennextjs/aws/logger.js";
1212

1313
import type { ProjectOptions } from "../project-options.js";
1414
import { bundleServer } from "./bundle-server.js";
15+
import { compileCacheAssetsManifestSqlFile } from "./open-next/compile-cache-assets-manifest.js";
1516
import { compileEnvFiles } from "./open-next/compile-env-files.js";
1617
import { copyCacheAssets } from "./open-next/copyCacheAssets.js";
1718
import { createServerBundle } from "./open-next/createServerBundle.js";
@@ -86,8 +87,12 @@ export async function build(projectOpts: ProjectOptions): Promise<void> {
8687
createStaticAssets(options);
8788

8889
if (config.dangerous?.disableIncrementalCache !== true) {
89-
createCacheAssets(options);
90+
const { useTagCache, metaFiles } = createCacheAssets(options);
9091
copyCacheAssets(options);
92+
93+
if (useTagCache) {
94+
compileCacheAssetsManifestSqlFile(options, metaFiles);
95+
}
9196
}
9297

9398
await createServerBundle(options);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
2+
import path from "node:path";
3+
4+
import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
5+
import type { TagCacheMetaFile } from "@opennextjs/aws/types/cache.js";
6+
7+
/**
8+
* Generates SQL statements that can be used to initialise the cache assets manifest in an SQL data store.
9+
*/
10+
export function compileCacheAssetsManifestSqlFile(options: BuildOptions, metaFiles: TagCacheMetaFile[]) {
11+
const outputPath = path.join(options.outputDir, "cloudflare/cache-assets-manifest.sql");
12+
13+
const tagsTable = process.env.NEXT_CACHE_D1_TAGS_TABLE || "tags";
14+
const revalidationsTable = process.env.NEXT_CACHE_D1_REVALIDATIONS_TABLE || "revalidations";
15+
16+
mkdirSync(path.dirname(outputPath), { recursive: true });
17+
writeFileSync(
18+
outputPath,
19+
`CREATE TABLE IF NOT EXISTS ${JSON.stringify(tagsTable)} (tag TEXT NOT NULL, path TEXT NOT NULL, UNIQUE(tag, path) ON CONFLICT REPLACE);
20+
CREATE TABLE IF NOT EXISTS ${JSON.stringify(revalidationsTable)} (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, UNIQUE(tag) ON CONFLICT REPLACE);\n`
21+
);
22+
23+
const values = metaFiles.map(({ tag, path }) => `(${JSON.stringify(tag.S)}, ${JSON.stringify(path.S)})`);
24+
25+
if (values.length) {
26+
appendFileSync(
27+
outputPath,
28+
`INSERT INTO ${JSON.stringify(tagsTable)} (tag, path) VALUES ${values.join(", ")};`
29+
);
30+
}
31+
}

packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ export function ensureCloudflareConfig(config: OpenNextConfig) {
1313
dftMaybeUseCache:
1414
config.default?.override?.incrementalCache === "dummy" ||
1515
typeof config.default?.override?.incrementalCache === "function",
16-
dftUseDummyTagCache: config.default?.override?.tagCache === "dummy",
16+
dftMaybeUseTagCache:
17+
config.default?.override?.tagCache === "dummy" ||
18+
typeof config.default?.override?.incrementalCache === "function",
1719
dftMaybeUseQueue:
1820
config.default?.override?.queue === "dummy" ||
1921
config.default?.override?.queue === "direct" ||

packages/cloudflare/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@
1515
"target": "ES2022",
1616
"types": ["@cloudflare/workers-types", "@opennextjs/aws/types/global.d.ts"]
1717
},
18-
"include": ["src/**/*.ts"]
18+
"include": ["src/**/*.ts", "env.d.ts"]
1919
}

0 commit comments

Comments
 (0)