Skip to content

Commit d7286a3

Browse files
authored
Enhance cache stale checking logic (#1142)
* feat(cache): implement isStale check for cache entries and add related tests * feat(tagCache): enhance isStale logic to consider revalidatedAt timestamp * update comment * update e2e test * linting * linting * changeset * review
1 parent 4bdfd29 commit d7286a3

8 files changed

Lines changed: 221 additions & 14 deletions

File tree

.changeset/big-turtles-move.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@opennextjs/aws": patch
3+
---
4+
5+
Fix cache interceptor isStale handling for Next 16+
6+
Fix next mode tag cache not retrieving stale data properly

packages/open-next/src/core/routing/cacheInterceptor.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { CacheValue } from "types/overrides";
1010
import { emptyReadableStream, toReadableStream } from "utils/stream";
1111

1212
import { isBinaryContentType } from "utils/binary";
13-
import { getTagsFromValue, hasBeenRevalidated } from "utils/cache";
13+
import { getTagsFromValue, hasBeenRevalidated, isStale } from "utils/cache";
1414
import { debug, error } from "../../adapters/logger";
1515
import { localizePath } from "./i18n";
1616
import { generateMessageGroupId } from "./queue";
@@ -39,6 +39,7 @@ async function computeCacheControl(
3939
host: string,
4040
revalidate?: number | false,
4141
lastModified?: number,
42+
isStaleFromTagCache = false,
4243
) {
4344
let finalRevalidate = CACHE_ONE_YEAR;
4445

@@ -67,19 +68,26 @@ async function computeCacheControl(
6768
etag,
6869
};
6970
}
70-
if (finalRevalidate !== CACHE_ONE_YEAR) {
71-
const sMaxAge = Math.max(finalRevalidate - age, 1);
71+
72+
// SSG uses one year cache
73+
const isSSG = finalRevalidate === CACHE_ONE_YEAR;
74+
const remainingTtl = Math.max(finalRevalidate - age, 1);
75+
76+
const isStaleFromTime = !isSSG && remainingTtl === 1;
77+
const isStale = isStaleFromTime || isStaleFromTagCache;
78+
79+
if (!isSSG || isStaleFromTagCache) {
80+
const sMaxAge = isStaleFromTagCache ? 1 : remainingTtl;
7281
debug("sMaxAge", {
7382
finalRevalidate,
7483
age,
7584
lastModified,
7685
revalidate,
86+
isStaleFromTagCache,
7787
});
78-
const isStale = sMaxAge === 1;
7988
if (isStale) {
8089
let url = NextConfig.trailingSlash ? `${path}/` : path;
8190
if (NextConfig.basePath) {
82-
// We need to add the basePath to the url
8391
url = `${NextConfig.basePath}${url}`;
8492
}
8593
await globalThis.queue.send({
@@ -140,6 +148,7 @@ async function generateResult(
140148
localizedPath: string,
141149
cachedValue: CacheValue<"cache">,
142150
lastModified?: number,
151+
isStaleFromTagCache = false,
143152
): Promise<InternalResult> {
144153
debug("Returning result from experimental cache");
145154
let body = "";
@@ -172,6 +181,7 @@ async function generateResult(
172181
event.headers.host,
173182
cachedValue.revalidate,
174183
lastModified,
184+
isStaleFromTagCache,
175185
);
176186
return {
177187
type: "core",
@@ -277,13 +287,12 @@ export async function cacheInterceptor(
277287
if (!cachedData?.value) {
278288
return event;
279289
}
290+
const tags = getTagsFromValue(cachedData.value);
280291
// We need to check the tag cache now
281292
if (
282293
cachedData.value?.type === "app" ||
283294
cachedData.value?.type === "route"
284295
) {
285-
const tags = getTagsFromValue(cachedData.value);
286-
287296
const _hasBeenRevalidated = cachedData.shouldBypassTagCache
288297
? false
289298
: await hasBeenRevalidated(localizedPath, tags, cachedData);
@@ -292,6 +301,16 @@ export async function cacheInterceptor(
292301
return event;
293302
}
294303
}
304+
305+
// Check if the cache entry is stale (valid but needs background revalidation)
306+
const _isStale = cachedData.shouldBypassTagCache
307+
? false
308+
: await isStale(
309+
localizedPath,
310+
tags,
311+
cachedData.lastModified ?? Date.now(),
312+
);
313+
295314
const host = event.headers.host;
296315
switch (cachedData?.value?.type) {
297316
case "app":
@@ -301,6 +320,7 @@ export async function cacheInterceptor(
301320
localizedPath,
302321
cachedData.value,
303322
cachedData.lastModified,
323+
_isStale,
304324
);
305325
case "redirect": {
306326
const cacheControl = await computeCacheControl(
@@ -309,6 +329,7 @@ export async function cacheInterceptor(
309329
host,
310330
cachedData.value.revalidate,
311331
cachedData.lastModified,
332+
_isStale,
312333
);
313334
return {
314335
type: "core",
@@ -329,6 +350,7 @@ export async function cacheInterceptor(
329350
host,
330351
cachedData.value.revalidate,
331352
cachedData.lastModified,
353+
_isStale,
332354
);
333355

334356
const isBinary = isBinaryContentType(

packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,14 @@ export default {
208208

209209
const compute = (item: any): boolean => {
210210
if (!item?.stale?.N) return false;
211-
return Number.parseInt(item.stale.N) > (lastModified ?? 0);
211+
const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0");
212+
// A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page.
213+
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
214+
// after the page was generated, preventing a stale signal from a previous ISR cycle.
215+
return (
216+
revalidatedAt > (lastModified ?? 0) &&
217+
Number.parseInt(item.stale.N) >= (lastModified ?? 0)
218+
);
212219
};
213220

214221
const { uncachedTags, hasMatch } = checkItemsCache(

packages/open-next/src/overrides/tagCache/fs-dev-nextMode.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,13 @@ export default {
6161
if (!tagData || typeof tagData.stale !== "number") {
6262
return false;
6363
}
64-
return tagData.stale > (lastModified ?? 0);
64+
// A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page.
65+
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
66+
// after the page was generated, preventing a stale signal from a previous ISR cycle.
67+
return (
68+
tagData.revalidatedAt > (lastModified ?? 0) &&
69+
tagData.stale >= (lastModified ?? 0)
70+
);
6571
});
6672
debug("isStale result:", hasStaleTag);
6773
return hasStaleTag;

packages/open-next/src/overrides/tagCache/fs-dev.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,13 @@ const tagCache: TagCache = {
6868
return tags.some((entry) => {
6969
if (entry.path.S !== buildKey(path)) return false;
7070
if (!entry.stale?.N) return false;
71-
return Number.parseInt(entry.stale.N) > (lastModified ?? 0);
71+
// A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page.
72+
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
73+
// after the page was generated, preventing a stale signal from a previous ISR cycle.
74+
return (
75+
Number.parseInt(entry.revalidatedAt.N) > (lastModified ?? 0) &&
76+
Number.parseInt(entry.stale.N) > (lastModified ?? 0)
77+
);
7278
});
7379
},
7480
writeTags: async (newTags) => {

packages/tests-e2e/tests/appRouter/revalidateTag.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ test("Revalidate tag - stale data served first", async ({ page, request }) => {
113113
const staleHeaders = staleResponse.headers();
114114
const staleCache =
115115
staleHeaders["x-nextjs-cache"] ?? staleHeaders["x-opennext-cache"];
116-
expect(staleCache).toMatch(/^(STALE|HIT)$/);
116+
expect(staleCache).toMatch(/^(STALE)$/);
117117

118118
const staleTime = await page.getByTestId("cached-time").textContent();
119119
// Stale content must match the pre-revalidation value
@@ -137,6 +137,22 @@ test("Revalidate tag - stale data served first", async ({ page, request }) => {
137137
// After background regen the cached value must have been updated
138138
expect(freshTime).not.toBeNull();
139139
expect(freshTime).not.toEqual(originalTime);
140+
141+
// Now we want to verfiy that the next entries stays fresh (HIT) after the first stale entry
142+
responsePromise = page.waitForResponse(
143+
(response) =>
144+
response.url().includes("/revalidate-tag/stale") &&
145+
response.status() === 200,
146+
);
147+
await page.goto("/revalidate-tag/stale");
148+
const finalResponse = await responsePromise;
149+
const finalHeaders = finalResponse.headers();
150+
const finalCache =
151+
finalHeaders["x-nextjs-cache"] ?? finalHeaders["x-opennext-cache"];
152+
expect(finalCache).toEqual("HIT");
153+
154+
const finalTime = await page.getByTestId("cached-time").textContent();
155+
expect(finalTime).toEqual(freshTime);
140156
});
141157

142158
test("Revalidate path", async ({ page, request }) => {

packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@ const incrementalCache = {
5959

6060
const tagCache = {
6161
name: "mock",
62+
mode: "original",
6263
getByTag: vi.fn(),
6364
getByPath: vi.fn(),
6465
getLastModified: vi.fn(),
66+
isStale: vi.fn().mockResolvedValue(false),
6567
writeTags: vi.fn(),
6668
};
6769

@@ -74,6 +76,7 @@ declare global {
7476
var queue: Queue;
7577
var incrementalCache: any;
7678
var tagCache: any;
79+
var nextVersion: string;
7780
}
7881

7982
globalThis.incrementalCache = incrementalCache;
@@ -83,6 +86,7 @@ globalThis.queue = queue;
8386
beforeEach(() => {
8487
vi.useFakeTimers().setSystemTime("2024-01-02T00:00:00Z");
8588
vi.clearAllMocks();
89+
globalThis.nextVersion = "16.0.0";
8690
globalThis.openNextConfig = {
8791
dangerous: {
8892
disableTagCache: false,
@@ -653,4 +657,119 @@ describe("cacheInterceptor", () => {
653657
expect((result as any).headers["x-nextjs-postponed"]).toBeUndefined();
654658
});
655659
});
660+
661+
describe("isStale", () => {
662+
it("should serve stale app content when isStale returns true", async () => {
663+
const event = createEvent({ url: "/albums" });
664+
incrementalCache.get.mockResolvedValueOnce({
665+
value: {
666+
type: "app",
667+
html: "Hello, world!",
668+
},
669+
lastModified: new Date("2024-01-02T00:00:00Z").getTime(),
670+
});
671+
tagCache.isStale.mockResolvedValueOnce(true);
672+
673+
const result = await cacheInterceptor(event);
674+
675+
const body = await fromReadableStream(result.body);
676+
expect(body).toEqual("Hello, world!");
677+
expect(result).toEqual(
678+
expect.objectContaining({
679+
type: "core",
680+
headers: expect.objectContaining({
681+
"cache-control": "s-maxage=1, stale-while-revalidate=2592000",
682+
"x-opennext-cache": "STALE",
683+
}),
684+
}),
685+
);
686+
expect(queue.send).toHaveBeenCalled();
687+
});
688+
689+
it("should serve stale route content when isStale returns true", async () => {
690+
const event = createEvent({ url: "/albums" });
691+
incrementalCache.get.mockResolvedValueOnce({
692+
value: {
693+
type: "route",
694+
body: "API response",
695+
meta: {
696+
status: 200,
697+
headers: { "content-type": "application/json" },
698+
},
699+
revalidate: 300,
700+
},
701+
lastModified: new Date("2024-01-02T00:00:00Z").getTime(),
702+
});
703+
tagCache.isStale.mockResolvedValueOnce(true);
704+
705+
const result = await cacheInterceptor(event);
706+
707+
expect(result).toEqual(
708+
expect.objectContaining({
709+
headers: expect.objectContaining({
710+
"cache-control": "s-maxage=1, stale-while-revalidate=2592000",
711+
"x-opennext-cache": "STALE",
712+
}),
713+
}),
714+
);
715+
expect(queue.send).toHaveBeenCalled();
716+
});
717+
718+
it("should not check isStale when shouldBypassTagCache is true", async () => {
719+
const event = createEvent({ url: "/albums" });
720+
incrementalCache.get.mockResolvedValueOnce({
721+
value: {
722+
type: "app",
723+
html: "Hello, world!",
724+
},
725+
lastModified: new Date("2024-01-02T00:00:00Z").getTime(),
726+
shouldBypassTagCache: true,
727+
});
728+
729+
await cacheInterceptor(event);
730+
731+
expect(tagCache.isStale).not.toHaveBeenCalled();
732+
});
733+
734+
it("should not call isStale when nextVersion is below 16", async () => {
735+
globalThis.nextVersion = "15.0.0";
736+
const event = createEvent({ url: "/albums" });
737+
incrementalCache.get.mockResolvedValueOnce({
738+
value: {
739+
type: "app",
740+
html: "Hello, world!",
741+
},
742+
lastModified: new Date("2024-01-02T00:00:00Z").getTime(),
743+
});
744+
745+
await cacheInterceptor(event);
746+
747+
expect(tagCache.isStale).not.toHaveBeenCalled();
748+
});
749+
750+
it("should serve fresh content when isStale returns false", async () => {
751+
const event = createEvent({ url: "/albums" });
752+
incrementalCache.get.mockResolvedValueOnce({
753+
value: {
754+
type: "app",
755+
html: "Hello, world!",
756+
},
757+
lastModified: new Date("2024-01-02T00:00:00Z").getTime(),
758+
});
759+
tagCache.isStale.mockResolvedValueOnce(false);
760+
761+
const result = await cacheInterceptor(event);
762+
763+
expect(result).toEqual(
764+
expect.objectContaining({
765+
headers: expect.objectContaining({
766+
"cache-control":
767+
"s-maxage=31536000, stale-while-revalidate=2592000",
768+
"x-opennext-cache": "HIT",
769+
}),
770+
}),
771+
);
772+
expect(queue.send).not.toHaveBeenCalled();
773+
});
774+
});
656775
});

0 commit comments

Comments
 (0)