Skip to content
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

feat: add support for multiple revalidation at once #181

Merged
merged 4 commits into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ To facilitate this:
- 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`.
- The custom cache handler manages the cache files on S3, handling both reading and writing operations.
- 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.
- 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.
- The `revalidation-function` polls the message from the queue and makes a `HEAD` request to the route with the `x-prerender-revalidate` header.
- The `server-function` receives the `HEAD` request and revalidates the cache.

Expand Down
27 changes: 27 additions & 0 deletions examples/app-router/app/api/isr/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import fs from "fs/promises";
import { NextRequest, NextResponse } from "next/server";
import path from "path";

// This endpoint simulates an on demand revalidation request
export async function GET(request: NextRequest) {
const cwd = process.cwd();
const prerenderManifest = await fs.readFile(
path.join(cwd, ".next/prerender-manifest.json"),
"utf-8",
);
const manifest = JSON.parse(prerenderManifest);
const previewId = manifest.preview.previewModeId;

const result = await fetch(`https://${request.url}/isr`, {
headers: { "x-prerender-revalidate": previewId },
method: "HEAD",
});

return NextResponse.json({
status: 200,
body: {
result: result.ok,
cacheControl: result.headers.get("cache-control"),
},
});
}
63 changes: 59 additions & 4 deletions packages/open-next/src/adapters/plugins/routing/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { IncomingMessage } from "../../request.js";
import { ServerResponse } from "../../response.js";
import { loadBuildId, loadHtmlPages } from "../../util.js";

enum CommonHeaders {
CACHE_CONTROL = "cache-control",
}

// Expected environment variables
const { REVALIDATION_QUEUE_REGION, REVALIDATION_QUEUE_URL } = process.env;
const NEXT_DIR = path.join(__dirname, ".next");
Expand Down Expand Up @@ -48,8 +52,8 @@ export function fixCacheHeaderForHtmlPages(
headers: Record<string, string | undefined>,
) {
// 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
if (htmlPages.includes(rawPath) && headers["cache-control"]) {
headers["cache-control"] =
if (htmlPages.includes(rawPath) && headers[CommonHeaders.CACHE_CONTROL]) {
headers[CommonHeaders.CACHE_CONTROL] =
"public, max-age=0, s-maxage=31536000, must-revalidate";
}
}
Expand All @@ -74,13 +78,20 @@ export async function revalidateIfRequired(
headers: Record<string, string | undefined>,
req: IncomingMessage,
) {
// 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
if (headers["x-nextjs-cache"] === "REVALIDATED") {
headers[CommonHeaders.CACHE_CONTROL] =
"private, no-cache, no-store, max-age=0, must-revalidate";
return;
}
if (headers["x-nextjs-cache"] !== "STALE") return;

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

// If the URL is rewritten, revalidation needs to be done on the rewritten URL.
// - Link to Next.js doc: https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration#on-demand-revalidation
Expand Down Expand Up @@ -113,11 +124,55 @@ export async function revalidateIfRequired(
QueueUrl: REVALIDATION_QUEUE_URL,
MessageDeduplicationId: hash(`${rawPath}-${headers.etag}`),
MessageBody: JSON.stringify({ host, url: revalidateUrl }),
MessageGroupId: "revalidate",
MessageGroupId: generateMessageGroupId(rawPath),
}),
);
} catch (e) {
debug(`Failed to revalidate stale page ${rawPath}`);
debug(e);
}
}

// Since we're using a FIFO queue, every messageGroupId is treated sequentially
// This could cause a backlog of messages in the queue if there is too much page to
// revalidate at once. To avoid this, we generate a random messageGroupId for each
// revalidation request.
// We can't just use a random string because we need to ensure that the same rawPath
// will always have the same messageGroupId.
// https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript#answer-47593316
function generateMessageGroupId(rawPath: string) {
let a = cyrb128(rawPath);
// We use mulberry32 to generate a random int between 0 and MAX_REVALIDATE_CONCURRENCY
var t = (a += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
const randomFloat = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
// This will generate a random int between 0 and MAX_REVALIDATE_CONCURRENCY
// This means that we could have 1000 revalidate request at the same time
const maxConcurrency = parseInt(
process.env.MAX_REVALIDATE_CONCURRENCY ?? "10",
);
const randomInt = Math.floor(randomFloat * maxConcurrency);
return `revalidate-${randomInt}`;
}

// Used to generate a hash int from a string
function cyrb128(str: string) {
let h1 = 1779033703,
h2 = 3144134277,
h3 = 1013904242,
h4 = 2773480762;
for (let i = 0, k; i < str.length; i++) {
k = str.charCodeAt(i);
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
}
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
(h1 ^= h2 ^ h3 ^ h4), (h2 ^= h1), (h3 ^= h1), (h4 ^= h1);
return h1 >>> 0;
}
7 changes: 5 additions & 2 deletions packages/open-next/src/adapters/revalidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from "node:path";

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

import { debug } from "./logger.js";
import { debug, error } from "./logger.js";

const prerenderManifest = loadPrerenderManifest();

Expand Down Expand Up @@ -41,7 +41,10 @@ export const handler = async (event: SQSEvent) => {
},
(res) => resolve(res),
);
req.on("error", (err) => reject(err));
req.on("error", (err) => {
error(`Error revalidating page`, { host, url });
reject(err);
});
req.end();
});
}
Expand Down
12 changes: 12 additions & 0 deletions packages/tests-e2e/tests/appRouter/isr.revalidate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expect, test } from "@playwright/test";

test("Test revalidate", async ({ request }) => {
const result = await request.get("/api/isr");

expect(result.status()).toEqual(200);
const body = await result.json();
expect(body.result).toEqual(true);
expect(body.cacheControl).toEqual(
"private, no-cache, no-store, max-age=0, must-revalidate",
);
});
Loading