Skip to content

Commit 8672cf3

Browse files
committed
feat: port stale-while-revalidate tag revalidation from AWS
Ports opennextjs-aws #1122 and #1142 on top of the cache handler function. - Tags carry optional `stale` and `expire` durations: `writeTags` accepts a `{ tag, stale, expire }` input as well as a plain name, `OriginalTagCache` entries gained the same fields, and both tag cache flavours gained an optional `isStale`. - `Cache.revalidateTags` takes the durations; the cache handler function turns the `expire` delay into a timestamp, callers pass it through unchanged. - The composable cache handler implements `updateTags`, added in Next.js 16. - New per-request `RequestCache` on the OpenNext request context, so overrides can deduplicate work within a request without touching global state, and `globalThis.nextVersion` is injected in the esbuild banner. - DynamoDB, fs-dev and the Cloudflare D1 / KV / sharded DO tag caches are updated for the new signatures.
1 parent dc444fc commit 8672cf3

33 files changed

Lines changed: 975 additions & 151 deletions

.changeset/swr-tag-revalidation.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@opennextjs/core": minor
3+
"@opennextjs/aws": minor
4+
---
5+
6+
Port stale-while-revalidate tag revalidation from AWS
7+
8+
Ports [#1122](https://github.com/opennextjs/opennextjs-aws/pull/1122) and
9+
[#1142](https://github.com/opennextjs/opennextjs-aws/pull/1142).
10+
11+
Tags can now carry `stale` and `expire` durations, so an entry can be served stale while it
12+
revalidates instead of being dropped outright. `writeTags` accepts either a tag name or a
13+
`{ tag, stale, expire }` object, both tag cache flavours gained an optional `isStale`, and the
14+
composable cache handler implements the `updateTags` method added in Next.js 16.
15+
16+
Tag cache overrides that need to deduplicate work within a request can use the new per-request
17+
`requestCache` on the OpenNext request context.

packages/aws/src/overrides/tagCache/dynamodb-lite.ts

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ type DynamoDBItem = {
1414
tag?: { S: string };
1515
path?: { S: string };
1616
revalidatedAt?: { N: string };
17+
stale?: { N: string };
18+
expire?: { N: string };
1719
};
1820

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

59-
function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) {
61+
function buildDynamoObject(
62+
path: string,
63+
tags: string,
64+
revalidatedAt?: number,
65+
stale?: number,
66+
expire?: number
67+
) {
6068
return {
6169
path: { S: buildDynamoKey(path) },
6270
tag: { S: buildDynamoKey(tags) },
6371
revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` },
72+
...(stale !== undefined ? { stale: { N: `${stale}` } } : {}),
73+
...(expire !== undefined ? { expire: { N: `${expire}` } } : {}),
6474
};
6575
}
6676

@@ -72,6 +82,11 @@ const tagCache: OriginalTagCache = {
7282
return [];
7383
}
7484
const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env;
85+
const store = globalThis.__openNextAls.getStore();
86+
const cache = store?.requestCache.getOrCreate<string, string[]>("dynamoDb:getByPath");
87+
if (cache?.has(path)) {
88+
return cache.get(path)!;
89+
}
7590
const result = await awsFetch(
7691
JSON.stringify({
7792
TableName: CACHE_DYNAMO_TABLE,
@@ -93,7 +108,9 @@ const tagCache: OriginalTagCache = {
93108
const tags = Items?.map((item) => item.tag?.S ?? "") ?? [];
94109
debug("tags for path", path, tags);
95110
// We need to remove the buildId from the path
96-
return tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, ""));
111+
const resultTags = tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, ""));
112+
cache?.set(path, resultTags);
113+
return resultTags;
97114
} catch (e) {
98115
error("Failed to get tags by path", e);
99116
return [];
@@ -105,6 +122,11 @@ const tagCache: OriginalTagCache = {
105122
return [];
106123
}
107124
const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env;
125+
const store = globalThis.__openNextAls.getStore();
126+
const cache = store?.requestCache.getOrCreate<string, string[]>("dynamoDb:getByTag");
127+
if (cache?.has(tag)) {
128+
return cache.get(tag)!;
129+
}
108130
const result = await awsFetch(
109131
JSON.stringify({
110132
TableName: CACHE_DYNAMO_TABLE,
@@ -121,10 +143,9 @@ const tagCache: OriginalTagCache = {
121143
throw new RecoverableError(`Failed to get by tag: ${result.status}`);
122144
}
123145
const { Items } = (await result.json()) as DynamoDBResponse;
124-
return (
125-
// We need to remove the buildId from the path
126-
Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []
127-
);
146+
const paths = Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [];
147+
cache?.set(tag, paths);
148+
return paths;
128149
} catch (e) {
129150
error("Failed to get by tag", e);
130151
return [];
@@ -136,6 +157,12 @@ const tagCache: OriginalTagCache = {
136157
return lastModified ?? Date.now();
137158
}
138159
const { CACHE_DYNAMO_TABLE } = process.env;
160+
const store = globalThis.__openNextAls.getStore();
161+
const cache = store?.requestCache.getOrCreate<string, number>("dynamoDb:getLastModified");
162+
const cacheKey = `${key}:${lastModified ?? 0}`;
163+
if (cache?.has(cacheKey)) {
164+
return cache.get(cacheKey)!;
165+
}
139166
const result = await awsFetch(
140167
JSON.stringify({
141168
TableName: CACHE_DYNAMO_TABLE,
@@ -156,14 +183,80 @@ const tagCache: OriginalTagCache = {
156183
}
157184
const revalidatedTags = ((await result.json()) as DynamoDBResponse).Items ?? [];
158185
debug("revalidatedTags", revalidatedTags);
159-
// If we have revalidated tags we return -1 to force revalidation
160-
return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now());
186+
187+
// Check if any tag has expired
188+
const now = Date.now();
189+
190+
const hasExpiredTag = revalidatedTags.some((item) => {
191+
if (item.expire?.N) {
192+
const expiry = Number.parseInt(item.expire.N);
193+
return expiry <= now && expiry > (lastModified ?? 0);
194+
}
195+
return false;
196+
});
197+
// Exclude expired tags from the revalidated count — they are handled
198+
// separately via hasExpiredTag above.
199+
const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => {
200+
if (item.expire?.N) {
201+
return Number.parseInt(item.expire.N) === Number.parseInt(item.revalidatedAt?.N ?? "0");
202+
}
203+
return true;
204+
});
205+
// If we have revalidated tags or expired tags we return -1 to force revalidation
206+
const resultValue =
207+
nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now());
208+
cache?.set(cacheKey, resultValue);
209+
return resultValue;
161210
} catch (e) {
162211
error("Failed to get revalidated tags", e);
163212
return lastModified ?? Date.now();
164213
}
165214
},
166-
async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]) {
215+
async isStale(key: string, lastModified?: number) {
216+
try {
217+
if (globalThis.openNextConfig.dangerous?.disableTagCache) {
218+
return false;
219+
}
220+
const { CACHE_DYNAMO_TABLE } = process.env;
221+
const store = globalThis.__openNextAls.getStore();
222+
const itemsCache = store?.requestCache.getOrCreate<string, DynamoDBItem[]>(
223+
"dynamoDb:revalidateQueryItems"
224+
);
225+
const cacheKey = `${key}:${lastModified ?? 0}`;
226+
let items: DynamoDBItem[];
227+
if (itemsCache?.has(cacheKey)) {
228+
items = itemsCache.get(cacheKey)!;
229+
} else {
230+
// We can reuse the same query as getLastModified since it already checks for revalidatedAt > lastModified as revalidatedAt and stale have the same value
231+
const result = await awsFetch(
232+
JSON.stringify({
233+
TableName: CACHE_DYNAMO_TABLE,
234+
IndexName: "revalidate",
235+
KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified",
236+
ExpressionAttributeNames: {
237+
"#key": "path",
238+
"#revalidatedAt": "revalidatedAt",
239+
},
240+
ExpressionAttributeValues: {
241+
":key": { S: buildDynamoKey(key) },
242+
":lastModified": { N: String(lastModified ?? 0) },
243+
},
244+
})
245+
);
246+
if (result.status !== 200) {
247+
throw new RecoverableError(`Failed to check stale tags: ${result.status}`);
248+
}
249+
items = ((await result.json()) as DynamoDBResponse).Items ?? [];
250+
itemsCache?.set(cacheKey, items);
251+
}
252+
debug("isStale items", key, items);
253+
return items.length > 0;
254+
} catch (e) {
255+
error("Failed to check stale tags", e);
256+
return false;
257+
}
258+
},
259+
async writeTags(tags) {
167260
try {
168261
const { CACHE_DYNAMO_TABLE } = process.env;
169262
if (globalThis.openNextConfig.dangerous?.disableTagCache) {
@@ -174,7 +267,7 @@ const tagCache: OriginalTagCache = {
174267
[CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({
175268
PutRequest: {
176269
Item: {
177-
...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt),
270+
...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire),
178271
},
179272
},
180273
})),

0 commit comments

Comments
 (0)