Skip to content

Commit 1e8d232

Browse files
conico974vicb
andauthored
Fix tag cache stale logic and update tests (#1193)
Co-authored-by: Victor Berchet <victor@suumit.com>
1 parent a2679bf commit 1e8d232

11 files changed

Lines changed: 966 additions & 583 deletions

File tree

.changeset/true-moments-clap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
Fix tag cache stale logic

examples/e2e/app-router/e2e/revalidateTag.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ test("Revalidate tag - stale data served first", async ({ page, request }) => {
105105
const staleResponse = await responsePromise;
106106
const staleHeaders = staleResponse.headers();
107107
const staleCache = staleHeaders["x-nextjs-cache"] ?? staleHeaders["x-opennext-cache"];
108-
expect(staleCache).toMatch(/^(STALE|HIT)$/);
108+
expect(staleCache).toMatch(/^(STALE)$/);
109109

110110
const staleTime = await page.getByTestId("cached-time").textContent();
111111
// Stale content must match the pre-revalidation value
@@ -129,6 +129,19 @@ test("Revalidate tag - stale data served first", async ({ page, request }) => {
129129
// After background regen the cached value must have been updated
130130
expect(freshTime).not.toBeNull();
131131
expect(freshTime).not.toEqual(originalTime);
132+
133+
// Now we want to verfiy that the next entries stays fresh (HIT) after the first stale entry
134+
responsePromise = page.waitForResponse(
135+
(response) => response.url().includes("/revalidate-tag/stale") && response.status() === 200
136+
);
137+
await page.goto("/revalidate-tag/stale");
138+
const finalResponse = await responsePromise;
139+
const finalHeaders = finalResponse.headers();
140+
const finalCache = finalHeaders["x-nextjs-cache"] ?? finalHeaders["x-opennext-cache"];
141+
expect(finalCache).toEqual("HIT");
142+
143+
const finalTime = await page.getByTestId("cached-time").textContent();
144+
expect(finalTime).toEqual(freshTime);
132145
});
133146

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

packages/cloudflare/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"dependencies": {
5555
"@ast-grep/napi": "^0.40.5",
5656
"@dotenvx/dotenvx": "catalog:",
57-
"@opennextjs/aws": "3.10.2",
57+
"@opennextjs/aws": "3.10.3",
5858
"ci-info": "^4.2.0",
5959
"cloudflare": "^4.4.1",
6060
"comment-json": "^4.5.1",

packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
IncrementalCache,
66
WithLastModified,
77
} from "@opennextjs/aws/types/overrides.js";
8+
import { compareSemver } from "@opennextjs/aws/utils/semver.js";
89

910
import { getCloudflareContext } from "../../cloudflare-context.js";
1011
import { debugCache, FALLBACK_BUILD_ID, IncrementalCacheEntry, isPurgeCacheEnabled } from "../internal.js";
@@ -31,21 +32,33 @@ type Options = {
3132
defaultLongLivedTtlSec?: number;
3233

3334
/**
34-
* Whether the regional cache entry should be updated in the background or not when it experiences
35-
* a cache hit.
35+
* Whether the regional cache entry should be updated in the background on regional cache hits.
3636
*
37-
* @default `true` in `long-lived` mode when cache purge is not used, `false` otherwise.
37+
* NOTE: Use the default value unless you know what you are doing. It is set to:
38+
* - Next < 16:
39+
* `true` in `long-lived` mode when cache purge is not used, `false` otherwise.
40+
* - Next >= 16:
41+
* `!bypassTagCacheOnCacheHit`
3842
*/
3943
shouldLazilyUpdateOnCacheHit?: boolean;
4044

4145
/**
42-
* Whether on cache hits the tagCache should be skipped or not. Skipping the tagCache allows requests to be
43-
* handled faster,
46+
* Whether the tagCache should be skipped on regional cache hits.
4447
*
45-
* Note: When this is enabled, make sure that the cache gets purged
46-
* either by enabling the auto cache purging feature or manually.
48+
* Note:
49+
* - Skipping the tagCache allows requests to be handled faster
50+
* - When `true`, make sure the cache gets purged
51+
* either by enabling the auto cache purging feature or manually
4752
*
48-
* @default `true` if the auto cache purging is enabled, `false` otherwise.
53+
* `true` is not compatible with SWR types of revalidateTag
54+
* i.e. on Next 16+, anything different than `revalidateTag("tag", { expire: 0 })`.
55+
* That's why the default is `false` for Next 16+ which uses SWR by default.
56+
*
57+
* NOTE: Use the default value unless you know what you are doing. It is set to:
58+
* - Next <16:
59+
* `true` if the auto cache purging is enabled, `false` otherwise.
60+
* - Next >= 16:
61+
* `false`
4962
*/
5063
bypassTagCacheOnCacheHit?: boolean;
5164
};
@@ -78,17 +91,33 @@ class RegionalCache implements IncrementalCache {
7891
private opts: Options
7992
) {
8093
this.name = this.store.name;
81-
// `shouldLazilyUpdateOnCacheHit` is not needed when cache purge is enabled.
82-
this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived" && !isPurgeCacheEnabled();
83-
}
8494

85-
get #bypassTagCacheOnCacheHit(): boolean {
86-
if (this.opts.bypassTagCacheOnCacheHit !== undefined) {
87-
return this.opts.bypassTagCacheOnCacheHit;
95+
// `globalThis.nextVersion` is only defined at runtime but not when the Open Next build runs.
96+
// The options do no matter at build time so we can skip setting them.
97+
const { nextVersion } = globalThis;
98+
if (nextVersion) {
99+
if (compareSemver(nextVersion, "<", "16")) {
100+
// Next < 16
101+
this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived" && !isPurgeCacheEnabled();
102+
this.opts.bypassTagCacheOnCacheHit ??= isPurgeCacheEnabled();
103+
} else {
104+
// Next >= 16
105+
this.opts.bypassTagCacheOnCacheHit ??= false;
106+
if (this.opts.bypassTagCacheOnCacheHit) {
107+
debugCache(
108+
"RegionalCache",
109+
`bypassTagCacheOnCacheHit is not recommended for Next 16+ as it is not compatible with SWR tags. Make sure to always use \`revalidateTag\` with \`{ expire: 0 }\` if you want to bypass the tag cache.`
110+
);
111+
}
112+
this.opts.shouldLazilyUpdateOnCacheHit ??= !this.opts.bypassTagCacheOnCacheHit;
113+
if (this.opts.shouldLazilyUpdateOnCacheHit !== this.opts.bypassTagCacheOnCacheHit) {
114+
debugCache(
115+
"RegionalCache",
116+
`\`shouldLazilyUpdateOnCacheHit\` and \`bypassTagCacheOnCacheHit\` are mutually exclusive for Next 16+.`
117+
);
118+
}
119+
}
88120
}
89-
90-
// When `bypassTagCacheOnCacheHit` is not set, we default to whether the automatic cache purging is enabled or not
91-
return isPurgeCacheEnabled();
92121
}
93122

94123
async get<CacheType extends CacheEntryType = "cache">(
@@ -123,7 +152,7 @@ class RegionalCache implements IncrementalCache {
123152

124153
return {
125154
...responseJson,
126-
shouldBypassTagCache: this.#bypassTagCacheOnCacheHit,
155+
shouldBypassTagCache: this.opts.bypassTagCacheOnCacheHit,
127156
};
128157
}
129158

packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,17 @@ describe("D1NextModeTagCache", () => {
395395
expect(result).toBe(false);
396396
});
397397

398+
it("should return false when revalidatedAt <= lastModified even if stale > lastModified", async () => {
399+
const now = 2000;
400+
vi.spyOn(Date, "now").mockReturnValue(now);
401+
// revalidatedAt=500 <= lastModified=1000, so the stale window is from a previous ISR cycle
402+
mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 500, 1500, null]]);
403+
404+
const result = await tagCache.isStale(["tag1"], 1000);
405+
406+
expect(result).toBe(false);
407+
});
408+
398409
it("should return false when expire <= now (tag expired)", async () => {
399410
const now = 2000;
400411
vi.spyOn(Date, "now").mockReturnValue(now);

packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,14 @@ export class D1NextModeTagCache implements NextModeTagCache {
105105

106106
const isStale = [...result.values()].some((v) => {
107107
if (v == null) return false;
108-
const { stale, expire } = v;
109-
if (stale == null || stale <= (lastModified ?? now)) return false;
108+
const { revalidatedAt, stale, expire } = v;
109+
// A tag is stale when both its stale and revalidatedAt timestamps are newer than the page.
110+
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
111+
// after the page was generated, preventing a stale signal from a previous ISR cycle.
112+
const lastModifiedOrNow = lastModified ?? now;
113+
const isInStaleWindow =
114+
stale != null && revalidatedAt > lastModifiedOrNow && lastModifiedOrNow <= stale;
115+
if (!isInStaleWindow) return false;
110116
return expire == null || expire > now;
111117
});
112118

packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,14 @@ describe("DOShardedTagCache", () => {
349349
expect(await cache.isStale(["tag1"], 200)).toBe(false);
350350
});
351351

352+
it("should return false when revalidatedAt <= lastModified even if stale > lastModified", async () => {
353+
const cache = shardedDOTagCache();
354+
cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]);
355+
// revalidatedAt=100 <= lastModified=200, so the stale window is from a previous ISR cycle
356+
getTagDataMock.mockResolvedValueOnce({ tag1: { revalidatedAt: 100, stale: 300, expire: null } });
357+
expect(await cache.isStale(["tag1"], 200)).toBe(false);
358+
});
359+
352360
it("should return from regional cache if available", async () => {
353361
vi.useFakeTimers();
354362
vi.setSystemTime(500);

packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,14 @@ class ShardedDOTagCache implements NextModeTagCache {
191191
const tagData = await this.#resolveTagData(tags);
192192
const result = [...tagData.values()].some((data) => {
193193
if (data == null) return false;
194-
const { stale, expire } = data;
195-
if (stale == null || stale <= (lastModified ?? now)) return false;
194+
const { revalidatedAt, stale, expire } = data;
195+
// A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page.
196+
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
197+
// after the page was generated, preventing a stale signal from a previous ISR cycle.
198+
const lastModifiedOrNow = lastModified ?? now;
199+
const isInStaleWindow =
200+
stale != null && revalidatedAt > lastModifiedOrNow && lastModifiedOrNow <= stale;
201+
if (!isInStaleWindow) return false;
196202
return expire == null || expire > now;
197203
});
198204
debugCache("ShardedDOTagCache", `isStale tags=${tags} at=${lastModified} -> ${result}`);

packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,19 @@ describe("KVNextModeTagCache", () => {
344344
expect(result).toBe(false);
345345
});
346346

347+
it("should return false when revalidatedAt <= lastModified even if stale > lastModified", async () => {
348+
const now = 2000;
349+
vi.spyOn(Date, "now").mockReturnValue(now);
350+
// revalidatedAt=500 <= lastModified=1000, so the stale window is from a previous ISR cycle
351+
mockGet.mockResolvedValue(
352+
new Map([[`${FALLBACK_BUILD_ID}/tag1`, { revalidatedAt: 500, stale: 1500, expire: null }]])
353+
);
354+
355+
const result = await tagCache.isStale(["tag1"], 1000);
356+
357+
expect(result).toBe(false);
358+
});
359+
347360
it("should return false when expire <= now (tag expired)", async () => {
348361
const now = 2000;
349362
vi.spyOn(Date, "now").mockReturnValue(now);

packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,14 @@ export class KVNextModeTagCache implements NextModeTagCache {
180180
const isStale = [...result.values()].some((v) => {
181181
if (v == null) return false;
182182
const stale = getStale(v);
183-
if (stale == null || stale <= (lastModified ?? now)) return false;
184183
const expire = getExpire(v);
184+
// A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page.
185+
// revalidatedAt > lastModified ensures the revalidation that set this stale window happened
186+
// after the page was generated, preventing a stale signal from a previous ISR cycle.
187+
const lastModifiedOrNow = lastModified ?? now;
188+
const isInStaleWindow =
189+
stale != null && getRevalidatedAt(v) > lastModifiedOrNow && lastModifiedOrNow <= stale;
190+
if (!isInStaleWindow) return false;
185191
return expire == null || expire > now;
186192
});
187193

0 commit comments

Comments
 (0)