Skip to content

Commit 82bb588

Browse files
authoredFeb 12, 2025··
feat: basic in-memory de-duping revalidation queue (#360)
* feat: basic in-memory de-duping revalidation queue * change to pr for package * add tests and fix mistake * remove dependency on memory queue from kv cache * Revert "remove dependency on memory queue from kv cache" This reverts commit 42739ae. * remove dependency on memory queue from kv cache again * move manifest retrievel to own util for mocking * get test to be reliable * configurable revalidation timeout * split up tests * review comments
1 parent 0c26049 commit 82bb588

File tree

6 files changed

+134
-4
lines changed

6 files changed

+134
-4
lines changed
 

Diff for: ‎.changeset/witty-baboons-smile.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": minor
3+
---
4+
5+
feat: basic in-memory de-duping revalidation queue

Diff for: ‎examples/e2e/app-router/open-next.config.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
22
import cache from "@opennextjs/cloudflare/kv-cache";
3+
import memoryQueue from "@opennextjs/cloudflare/memory-queue";
34

45
const config: OpenNextConfig = {
56
default: {
67
override: {
78
wrapper: "cloudflare-node",
89
converter: "edge",
910
incrementalCache: async () => cache,
10-
queue: "direct",
11+
queue: () => memoryQueue,
1112
// Unused implementation
1213
tagCache: "dummy",
1314
},

Diff for: ‎packages/cloudflare/src/api/memory-queue.spec.ts

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { generateMessageGroupId } from "@opennextjs/aws/core/routing/queue.js";
2+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
3+
4+
import cache, { DEFAULT_REVALIDATION_TIMEOUT_MS } from "./memory-queue";
5+
6+
vi.mock("./.next/prerender-manifest.json", () => Promise.resolve({ preview: { previewModeId: "id" } }));
7+
8+
describe("MemoryQueue", () => {
9+
beforeAll(() => {
10+
vi.useFakeTimers();
11+
globalThis.internalFetch = vi.fn().mockReturnValue(new Promise((res) => setTimeout(() => res(true), 1)));
12+
});
13+
14+
afterEach(() => vi.clearAllMocks());
15+
16+
it("should process revalidations for a path", async () => {
17+
const firstRequest = cache.send({
18+
MessageBody: { host: "test.local", url: "/test" },
19+
MessageGroupId: generateMessageGroupId("/test"),
20+
MessageDeduplicationId: "",
21+
});
22+
vi.advanceTimersByTime(DEFAULT_REVALIDATION_TIMEOUT_MS);
23+
await firstRequest;
24+
expect(globalThis.internalFetch).toHaveBeenCalledTimes(1);
25+
26+
const secondRequest = cache.send({
27+
MessageBody: { host: "test.local", url: "/test" },
28+
MessageGroupId: generateMessageGroupId("/test"),
29+
MessageDeduplicationId: "",
30+
});
31+
vi.advanceTimersByTime(1);
32+
await secondRequest;
33+
expect(globalThis.internalFetch).toHaveBeenCalledTimes(2);
34+
});
35+
36+
it("should process revalidations for multiple paths", async () => {
37+
const firstRequest = cache.send({
38+
MessageBody: { host: "test.local", url: "/test" },
39+
MessageGroupId: generateMessageGroupId("/test"),
40+
MessageDeduplicationId: "",
41+
});
42+
vi.advanceTimersByTime(1);
43+
await firstRequest;
44+
expect(globalThis.internalFetch).toHaveBeenCalledTimes(1);
45+
46+
const secondRequest = cache.send({
47+
MessageBody: { host: "test.local", url: "/test" },
48+
MessageGroupId: generateMessageGroupId("/other"),
49+
MessageDeduplicationId: "",
50+
});
51+
vi.advanceTimersByTime(1);
52+
await secondRequest;
53+
expect(globalThis.internalFetch).toHaveBeenCalledTimes(2);
54+
});
55+
56+
it("should de-dupe revalidations", async () => {
57+
const requests = [
58+
cache.send({
59+
MessageBody: { host: "test.local", url: "/test" },
60+
MessageGroupId: generateMessageGroupId("/test"),
61+
MessageDeduplicationId: "",
62+
}),
63+
cache.send({
64+
MessageBody: { host: "test.local", url: "/test" },
65+
MessageGroupId: generateMessageGroupId("/test"),
66+
MessageDeduplicationId: "",
67+
}),
68+
];
69+
vi.advanceTimersByTime(1);
70+
await Promise.all(requests);
71+
expect(globalThis.internalFetch).toHaveBeenCalledTimes(1);
72+
});
73+
});

Diff for: ‎packages/cloudflare/src/api/memory-queue.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import logger from "@opennextjs/aws/logger.js";
2+
import type { Queue, QueueMessage } from "@opennextjs/aws/types/overrides.js";
3+
4+
export const DEFAULT_REVALIDATION_TIMEOUT_MS = 10_000;
5+
6+
/**
7+
* The Memory Queue offers basic ISR revalidation by directly requesting a revalidation of a route.
8+
*
9+
* It offers basic support for in-memory de-duping per isolate.
10+
*/
11+
export class MemoryQueue implements Queue {
12+
readonly name = "memory-queue";
13+
14+
revalidatedPaths = new Map<string, ReturnType<typeof setTimeout>>();
15+
16+
constructor(private opts = { revalidationTimeoutMs: DEFAULT_REVALIDATION_TIMEOUT_MS }) {}
17+
18+
async send({ MessageBody: { host, url }, MessageGroupId }: QueueMessage): Promise<void> {
19+
if (this.revalidatedPaths.has(MessageGroupId)) return;
20+
21+
this.revalidatedPaths.set(
22+
MessageGroupId,
23+
// force remove to allow new revalidations incase something went wrong
24+
setTimeout(() => this.revalidatedPaths.delete(MessageGroupId), this.opts.revalidationTimeoutMs)
25+
);
26+
27+
try {
28+
const protocol = host.includes("localhost") ? "http" : "https";
29+
30+
// TODO: Drop the import - https://github.com/opennextjs/opennextjs-cloudflare/issues/361
31+
// @ts-ignore
32+
const manifest = await import("./.next/prerender-manifest.json");
33+
await globalThis.internalFetch(`${protocol}://${host}${url}`, {
34+
method: "HEAD",
35+
headers: {
36+
"x-prerender-revalidate": manifest.preview.previewModeId,
37+
"x-isr": "1",
38+
},
39+
});
40+
} catch (e) {
41+
logger.error(e);
42+
} finally {
43+
clearTimeout(this.revalidatedPaths.get(MessageGroupId));
44+
this.revalidatedPaths.delete(MessageGroupId);
45+
}
46+
}
47+
}
48+
49+
export default new MemoryQueue();

Diff for: ‎packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ export function ensureCloudflareConfig(config: OpenNextConfig) {
1515
typeof config.default?.override?.incrementalCache === "function",
1616
dftUseDummyTagCache: config.default?.override?.tagCache === "dummy",
1717
dftMaybeUseQueue:
18-
config.default?.override?.queue === "dummy" || config.default?.override?.queue === "direct",
18+
config.default?.override?.queue === "dummy" ||
19+
config.default?.override?.queue === "direct" ||
20+
typeof config.default?.override?.queue === "function",
1921
disableCacheInterception: config.dangerous?.enableCacheInterception !== true,
2022
mwIsMiddlewareExternal: config.middleware?.external == true,
2123
mwUseCloudflareWrapper: config.middleware?.override?.wrapper === "cloudflare-edge",
@@ -37,7 +39,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) {
3739
converter: "edge",
3840
incrementalCache: "dummy" | function,
3941
tagCache: "dummy",
40-
queue: "dummy" | "direct",
42+
queue: "dummy" | "direct" | function,
4143
},
4244
},
4345

Diff for: ‎packages/cloudflare/tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"noPropertyAccessFromIndexSignature": false,
1414
"outDir": "./dist",
1515
"target": "ES2022",
16-
"types": ["@cloudflare/workers-types"]
16+
"types": ["@cloudflare/workers-types", "@opennextjs/aws/types/global.d.ts"]
1717
},
1818
"include": ["src/**/*.ts"]
1919
}

0 commit comments

Comments
 (0)
Please sign in to comment.