Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: basic in-memory de-duping revalidation queue #360

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Feb 12, 2025
5 changes: 5 additions & 0 deletions .changeset/witty-baboons-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": minor
---

feat: basic in-memory de-duping revalidation queue
3 changes: 2 additions & 1 deletion examples/e2e/app-router/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
import cache from "@opennextjs/cloudflare/kv-cache";
import memoryQueue from "@opennextjs/cloudflare/memory-queue";

const config: OpenNextConfig = {
default: {
override: {
wrapper: "cloudflare-node",
converter: "edge",
incrementalCache: async () => cache,
queue: "direct",
queue: () => memoryQueue,
// Unused implementation
tagCache: "dummy",
},
Expand Down
73 changes: 73 additions & 0 deletions packages/cloudflare/src/api/memory-queue.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { generateMessageGroupId } from "@opennextjs/aws/core/routing/queue.js";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";

import cache, { DEFAULT_REVALIDATION_TIMEOUT_MS } from "./memory-queue";

vi.mock("./.next/prerender-manifest.json", () => Promise.resolve({ preview: { previewModeId: "id" } }));

describe("MemoryQueue", () => {
beforeAll(() => {
vi.useFakeTimers();
globalThis.internalFetch = vi.fn().mockReturnValue(new Promise((res) => setTimeout(() => res(true), 1)));
});

afterEach(() => vi.clearAllMocks());

it("should process revalidations for a path", async () => {
const firstRequest = cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/test"),
MessageDeduplicationId: "",
});
vi.advanceTimersByTime(DEFAULT_REVALIDATION_TIMEOUT_MS);
await firstRequest;
expect(globalThis.internalFetch).toHaveBeenCalledTimes(1);

const secondRequest = cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/test"),
MessageDeduplicationId: "",
});
vi.advanceTimersByTime(1);
await secondRequest;
expect(globalThis.internalFetch).toHaveBeenCalledTimes(2);
});

it("should process revalidations for multiple paths", async () => {
const firstRequest = cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/test"),
MessageDeduplicationId: "",
});
vi.advanceTimersByTime(1);
await firstRequest;
expect(globalThis.internalFetch).toHaveBeenCalledTimes(1);

const secondRequest = cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/other"),
MessageDeduplicationId: "",
});
vi.advanceTimersByTime(1);
await secondRequest;
expect(globalThis.internalFetch).toHaveBeenCalledTimes(2);
});

it("should de-dupe revalidations", async () => {
const requests = [
cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/test"),
MessageDeduplicationId: "",
}),
cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/test"),
MessageDeduplicationId: "",
}),
];
vi.advanceTimersByTime(1);
await Promise.all(requests);
expect(globalThis.internalFetch).toHaveBeenCalledTimes(1);
});
});
49 changes: 49 additions & 0 deletions packages/cloudflare/src/api/memory-queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import logger from "@opennextjs/aws/logger.js";
import type { Queue, QueueMessage } from "@opennextjs/aws/types/overrides.js";

export const DEFAULT_REVALIDATION_TIMEOUT_MS = 10_000;

/**
* The Memory Queue offers basic ISR revalidation by directly requesting a revalidation of a route.
*
* It offers basic support for in-memory de-duping per isolate.
*/
export class MemoryQueue implements Queue {
readonly name = "memory-queue";

revalidatedPaths = new Map<string, ReturnType<typeof setTimeout>>();

constructor(private opts = { revalidationTimeoutMs: DEFAULT_REVALIDATION_TIMEOUT_MS }) {}

async send({ MessageBody: { host, url }, MessageGroupId }: QueueMessage): Promise<void> {
if (this.revalidatedPaths.has(MessageGroupId)) return;

this.revalidatedPaths.set(
MessageGroupId,
// force remove to allow new revalidations incase something went wrong
setTimeout(() => this.revalidatedPaths.delete(MessageGroupId), this.opts.revalidationTimeoutMs)
);

try {
const protocol = host.includes("localhost") ? "http" : "https";

// TODO: Drop the import - https://github.com/opennextjs/opennextjs-cloudflare/issues/361
// @ts-ignore
const manifest = await import("./.next/prerender-manifest.json");
await globalThis.internalFetch(`${protocol}://${host}${url}`, {
method: "HEAD",
headers: {
"x-prerender-revalidate": manifest.preview.previewModeId,
"x-isr": "1",
},
});
} catch (e) {
logger.error(e);
} finally {
clearTimeout(this.revalidatedPaths.get(MessageGroupId));
this.revalidatedPaths.delete(MessageGroupId);
}
}
}

export default new MemoryQueue();
6 changes: 4 additions & 2 deletions packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export function ensureCloudflareConfig(config: OpenNextConfig) {
typeof config.default?.override?.incrementalCache === "function",
dftUseDummyTagCache: config.default?.override?.tagCache === "dummy",
dftMaybeUseQueue:
config.default?.override?.queue === "dummy" || config.default?.override?.queue === "direct",
config.default?.override?.queue === "dummy" ||
config.default?.override?.queue === "direct" ||
typeof config.default?.override?.queue === "function",
disableCacheInterception: config.dangerous?.enableCacheInterception !== true,
mwIsMiddlewareExternal: config.middleware?.external == true,
mwUseCloudflareWrapper: config.middleware?.override?.wrapper === "cloudflare-edge",
Expand All @@ -37,7 +39,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) {
converter: "edge",
incrementalCache: "dummy" | function,
tagCache: "dummy",
queue: "dummy" | "direct",
queue: "dummy" | "direct" | function,
},
},

Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"noPropertyAccessFromIndexSignature": false,
"outDir": "./dist",
"target": "ES2022",
"types": ["@cloudflare/workers-types"]
"types": ["@cloudflare/workers-types", "@opennextjs/aws/types/global.d.ts"]
},
"include": ["src/**/*.ts"]
}