Skip to content
Open
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
17 changes: 17 additions & 0 deletions .changeset/swr-tag-revalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@opennextjs/core": minor
"@opennextjs/aws": minor
---

Port stale-while-revalidate tag revalidation from AWS

Ports [#1122](https://github.com/opennextjs/opennextjs-aws/pull/1122) and
[#1142](https://github.com/opennextjs/opennextjs-aws/pull/1142).

Tags can now carry `stale` and `expire` durations, so an entry can be served stale while it
revalidates instead of being dropped outright. `writeTags` accepts either a tag name or a
`{ tag, stale, expire }` object, both tag cache flavours gained an optional `isStale`, and the
composable cache handler implements the `updateTags` method added in Next.js 16.

Tag cache overrides that need to deduplicate work within a request can use the new per-request
`requestCache` on the OpenNext request context.
113 changes: 103 additions & 10 deletions packages/aws/src/overrides/tagCache/dynamodb-lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type DynamoDBItem = {
tag?: { S: string };
path?: { S: string };
revalidatedAt?: { N: string };
stale?: { N: string };
expire?: { N: string };
};

type DynamoDBResponse = {
Expand Down Expand Up @@ -56,11 +58,19 @@ function buildDynamoKey(key: string) {
return path.posix.join(NEXT_BUILD_ID ?? "", key);
}

function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) {
function buildDynamoObject(
path: string,
tags: string,
revalidatedAt?: number,
stale?: number,
expire?: number
) {
return {
path: { S: buildDynamoKey(path) },
tag: { S: buildDynamoKey(tags) },
revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` },
...(stale !== undefined ? { stale: { N: `${stale}` } } : {}),
...(expire !== undefined ? { expire: { N: `${expire}` } } : {}),
};
}

Expand All @@ -72,6 +82,11 @@ const tagCache: OriginalTagCache = {
return [];
}
const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env;
const store = globalThis.__openNextAls.getStore();
const cache = store?.requestCache.getOrCreate<string, string[]>("dynamoDb:getByPath");
if (cache?.has(path)) {
return cache.get(path)!;
}
const result = await awsFetch(
JSON.stringify({
TableName: CACHE_DYNAMO_TABLE,
Expand All @@ -93,7 +108,9 @@ const tagCache: OriginalTagCache = {
const tags = Items?.map((item) => item.tag?.S ?? "") ?? [];
debug("tags for path", path, tags);
// We need to remove the buildId from the path
return tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, ""));
const resultTags = tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, ""));
cache?.set(path, resultTags);
return resultTags;
} catch (e) {
error("Failed to get tags by path", e);
return [];
Expand All @@ -105,6 +122,11 @@ const tagCache: OriginalTagCache = {
return [];
}
const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env;
const store = globalThis.__openNextAls.getStore();
const cache = store?.requestCache.getOrCreate<string, string[]>("dynamoDb:getByTag");
if (cache?.has(tag)) {
return cache.get(tag)!;
}
const result = await awsFetch(
JSON.stringify({
TableName: CACHE_DYNAMO_TABLE,
Expand All @@ -121,10 +143,9 @@ const tagCache: OriginalTagCache = {
throw new RecoverableError(`Failed to get by tag: ${result.status}`);
}
const { Items } = (await result.json()) as DynamoDBResponse;
return (
// We need to remove the buildId from the path
Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []
);
const paths = Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [];
cache?.set(tag, paths);
return paths;
} catch (e) {
error("Failed to get by tag", e);
return [];
Expand All @@ -136,6 +157,12 @@ const tagCache: OriginalTagCache = {
return lastModified ?? Date.now();
}
const { CACHE_DYNAMO_TABLE } = process.env;
const store = globalThis.__openNextAls.getStore();
const cache = store?.requestCache.getOrCreate<string, number>("dynamoDb:getLastModified");
const cacheKey = `${key}:${lastModified ?? 0}`;
if (cache?.has(cacheKey)) {
return cache.get(cacheKey)!;
}
const result = await awsFetch(
JSON.stringify({
TableName: CACHE_DYNAMO_TABLE,
Expand All @@ -156,14 +183,80 @@ const tagCache: OriginalTagCache = {
}
const revalidatedTags = ((await result.json()) as DynamoDBResponse).Items ?? [];
debug("revalidatedTags", revalidatedTags);
// If we have revalidated tags we return -1 to force revalidation
return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now());

// Check if any tag has expired
const now = Date.now();

const hasExpiredTag = revalidatedTags.some((item) => {
if (item.expire?.N) {
const expiry = Number.parseInt(item.expire.N);
return expiry <= now && expiry > (lastModified ?? 0);
}
return false;
});
// Exclude expired tags from the revalidated count — they are handled
// separately via hasExpiredTag above.
const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => {
if (item.expire?.N) {
return Number.parseInt(item.expire.N) === Number.parseInt(item.revalidatedAt?.N ?? "0");
}
return true;
});
// If we have revalidated tags or expired tags we return -1 to force revalidation
const resultValue =
nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now());
cache?.set(cacheKey, resultValue);
return resultValue;
} catch (e) {
error("Failed to get revalidated tags", e);
return lastModified ?? Date.now();
}
},
async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]) {
async isStale(key: string, lastModified?: number) {
try {
if (globalThis.openNextConfig.dangerous?.disableTagCache) {
return false;
}
const { CACHE_DYNAMO_TABLE } = process.env;
const store = globalThis.__openNextAls.getStore();
const itemsCache = store?.requestCache.getOrCreate<string, DynamoDBItem[]>(
"dynamoDb:revalidateQueryItems"
);
const cacheKey = `${key}:${lastModified ?? 0}`;
let items: DynamoDBItem[];
if (itemsCache?.has(cacheKey)) {
items = itemsCache.get(cacheKey)!;
} else {
// We can reuse the same query as getLastModified since it already checks for revalidatedAt > lastModified as revalidatedAt and stale have the same value
const result = await awsFetch(
JSON.stringify({
TableName: CACHE_DYNAMO_TABLE,
IndexName: "revalidate",
KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified",
ExpressionAttributeNames: {
"#key": "path",
"#revalidatedAt": "revalidatedAt",
},
ExpressionAttributeValues: {
":key": { S: buildDynamoKey(key) },
":lastModified": { N: String(lastModified ?? 0) },
},
})
);
if (result.status !== 200) {
throw new RecoverableError(`Failed to check stale tags: ${result.status}`);
}
items = ((await result.json()) as DynamoDBResponse).Items ?? [];
itemsCache?.set(cacheKey, items);
}
debug("isStale items", key, items);
return items.length > 0;
} catch (e) {
error("Failed to check stale tags", e);
return false;
}
},
async writeTags(tags) {
try {
const { CACHE_DYNAMO_TABLE } = process.env;
if (globalThis.openNextConfig.dangerous?.disableTagCache) {
Expand All @@ -174,7 +267,7 @@ const tagCache: OriginalTagCache = {
[CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({
PutRequest: {
Item: {
...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt),
...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire),
},
},
})),
Expand Down
Loading
Loading