Skip to content

Commit c8408f9

Browse files
authored
feat: add support for multiple revalidation at once (#181)
* feat: add support for multiple revalidation at once * docs: Add docs * fix: wrong cache-control for ISR revalidation * test: add e2e test for on demand revalidation
1 parent 58cd581 commit c8408f9

File tree

5 files changed

+104
-6
lines changed

5 files changed

+104
-6
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ To facilitate this:
269269
- The default cache handler is replaced with a custom cache handler by configuring the [`incrementalCacheHandlerPath`](https://nextjs.org/docs/app/api-reference/next-config-js/incrementalCacheHandlerPath) field in `next.config.js`.
270270
- The custom cache handler manages the cache files on S3, handling both reading and writing operations.
271271
- If the cache is stale, the `server-function` sends the stale response back to the user while sending a message to the revalidation queue to trigger background revalidation.
272+
- Since we're using FIFO queue, if we want to process more than one revalidation at a time, we need to have separate Message Group IDs. We generate a Message Group ID for each revalidation request based on the route path. This ensures that revalidation requests for the same route are processed only once. You can use `MAX_REVALIDATE_CONCURRENCY` environment variable to control the number of revalidation requests processed at a time. By default, it is set to 10.
272273
- The `revalidation-function` polls the message from the queue and makes a `HEAD` request to the route with the `x-prerender-revalidate` header.
273274
- The `server-function` receives the `HEAD` request and revalidates the cache.
274275

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import fs from "fs/promises";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import path from "path";
4+
5+
// This endpoint simulates an on demand revalidation request
6+
export async function GET(request: NextRequest) {
7+
const cwd = process.cwd();
8+
const prerenderManifest = await fs.readFile(
9+
path.join(cwd, ".next/prerender-manifest.json"),
10+
"utf-8",
11+
);
12+
const manifest = JSON.parse(prerenderManifest);
13+
const previewId = manifest.preview.previewModeId;
14+
15+
const result = await fetch(`https://${request.url}/isr`, {
16+
headers: { "x-prerender-revalidate": previewId },
17+
method: "HEAD",
18+
});
19+
20+
return NextResponse.json({
21+
status: 200,
22+
body: {
23+
result: result.ok,
24+
cacheControl: result.headers.get("cache-control"),
25+
},
26+
});
27+
}

packages/open-next/src/adapters/plugins/routing/util.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { IncomingMessage } from "../../request.js";
77
import { ServerResponse } from "../../response.js";
88
import { loadBuildId, loadHtmlPages } from "../../util.js";
99

10+
enum CommonHeaders {
11+
CACHE_CONTROL = "cache-control",
12+
}
13+
1014
// Expected environment variables
1115
const { REVALIDATION_QUEUE_REGION, REVALIDATION_QUEUE_URL } = process.env;
1216
const NEXT_DIR = path.join(__dirname, ".next");
@@ -48,8 +52,8 @@ export function fixCacheHeaderForHtmlPages(
4852
headers: Record<string, string | undefined>,
4953
) {
5054
// WORKAROUND: `NextServer` does not set cache headers for HTML pages — https://github.com/serverless-stack/open-next#workaround-nextserver-does-not-set-cache-headers-for-html-pages
51-
if (htmlPages.includes(rawPath) && headers["cache-control"]) {
52-
headers["cache-control"] =
55+
if (htmlPages.includes(rawPath) && headers[CommonHeaders.CACHE_CONTROL]) {
56+
headers[CommonHeaders.CACHE_CONTROL] =
5357
"public, max-age=0, s-maxage=31536000, must-revalidate";
5458
}
5559
}
@@ -79,13 +83,20 @@ export async function revalidateIfRequired(
7983
headers: Record<string, string | undefined>,
8084
req: IncomingMessage,
8185
) {
86+
// If the page has been revalidated via on demand revalidation, we need to remove the cache-control so that CloudFront doesn't cache the page
87+
if (headers["x-nextjs-cache"] === "REVALIDATED") {
88+
headers[CommonHeaders.CACHE_CONTROL] =
89+
"private, no-cache, no-store, max-age=0, must-revalidate";
90+
return;
91+
}
8292
if (headers["x-nextjs-cache"] !== "STALE") return;
8393

8494
// If the cache is stale, we revalidate in the background
8595
// In order for CloudFront SWR to work, we set the stale-while-revalidate value to 2 seconds
8696
// This will cause CloudFront to cache the stale data for a short period of time while we revalidate in the background
8797
// Once the revalidation is complete, CloudFront will serve the fresh data
88-
headers["cache-control"] = "s-maxage=2, stale-while-revalidate=2592000";
98+
headers[CommonHeaders.CACHE_CONTROL] =
99+
"s-maxage=2, stale-while-revalidate=2592000";
89100

90101
// If the URL is rewritten, revalidation needs to be done on the rewritten URL.
91102
// - Link to Next.js doc: https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration#on-demand-revalidation
@@ -118,11 +129,55 @@ export async function revalidateIfRequired(
118129
QueueUrl: REVALIDATION_QUEUE_URL,
119130
MessageDeduplicationId: hash(`${rawPath}-${headers.etag}`),
120131
MessageBody: JSON.stringify({ host, url: revalidateUrl }),
121-
MessageGroupId: "revalidate",
132+
MessageGroupId: generateMessageGroupId(rawPath),
122133
}),
123134
);
124135
} catch (e) {
125136
debug(`Failed to revalidate stale page ${rawPath}`);
126137
debug(e);
127138
}
128139
}
140+
141+
// Since we're using a FIFO queue, every messageGroupId is treated sequentially
142+
// This could cause a backlog of messages in the queue if there is too much page to
143+
// revalidate at once. To avoid this, we generate a random messageGroupId for each
144+
// revalidation request.
145+
// We can't just use a random string because we need to ensure that the same rawPath
146+
// will always have the same messageGroupId.
147+
// https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript#answer-47593316
148+
function generateMessageGroupId(rawPath: string) {
149+
let a = cyrb128(rawPath);
150+
// We use mulberry32 to generate a random int between 0 and MAX_REVALIDATE_CONCURRENCY
151+
var t = (a += 0x6d2b79f5);
152+
t = Math.imul(t ^ (t >>> 15), t | 1);
153+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
154+
const randomFloat = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
155+
// This will generate a random int between 0 and MAX_REVALIDATE_CONCURRENCY
156+
// This means that we could have 1000 revalidate request at the same time
157+
const maxConcurrency = parseInt(
158+
process.env.MAX_REVALIDATE_CONCURRENCY ?? "10",
159+
);
160+
const randomInt = Math.floor(randomFloat * maxConcurrency);
161+
return `revalidate-${randomInt}`;
162+
}
163+
164+
// Used to generate a hash int from a string
165+
function cyrb128(str: string) {
166+
let h1 = 1779033703,
167+
h2 = 3144134277,
168+
h3 = 1013904242,
169+
h4 = 2773480762;
170+
for (let i = 0, k; i < str.length; i++) {
171+
k = str.charCodeAt(i);
172+
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
173+
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
174+
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
175+
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
176+
}
177+
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
178+
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
179+
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
180+
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
181+
(h1 ^= h2 ^ h3 ^ h4), (h2 ^= h1), (h3 ^= h1), (h4 ^= h1);
182+
return h1 >>> 0;
183+
}

packages/open-next/src/adapters/revalidate.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import path from "node:path";
55

66
import type { SQSEvent } from "aws-lambda";
77

8-
import { debug } from "./logger.js";
8+
import { debug, error } from "./logger.js";
99

1010
const prerenderManifest = loadPrerenderManifest();
1111

@@ -42,7 +42,10 @@ export const handler = async (event: SQSEvent) => {
4242
},
4343
(res) => resolve(res),
4444
);
45-
req.on("error", (err) => reject(err));
45+
req.on("error", (err) => {
46+
error(`Error revalidating page`, { host, url });
47+
reject(err);
48+
});
4649
req.end();
4750
});
4851
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test("Test revalidate", async ({ request }) => {
4+
const result = await request.get("/api/isr");
5+
6+
expect(result.status()).toEqual(200);
7+
const body = await result.json();
8+
expect(body.result).toEqual(true);
9+
expect(body.cacheControl).toEqual(
10+
"private, no-cache, no-store, max-age=0, must-revalidate",
11+
);
12+
});

0 commit comments

Comments
 (0)