Cache: port SWR tag revalidation from AWS #1122 / #1142 - #46
Conversation
commit: |
| path: key, | ||
| tag, | ||
| revalidatedAt: 1, | ||
| revalidatedAt: Date.now(), |
There was a problem hiding this comment.
🔴 Newly cached pages are thrown away and rebuilt on the very next request
Freshly stored cache entries have their tag records stamped with the current time (revalidatedAt: Date.now() at packages/core/src/adapters/cache-adapter.ts:243) instead of the sentinel value that marks them as never revalidated, so the entry that was just saved immediately looks outdated and is discarded on the next read.
Impact: The first request after a page or fetch result is cached is a cache miss and triggers a redundant regeneration, doubling origin work for every newly cached entry.
Why the timestamp makes the entry look revalidated
In handleSet, the incremental cache entry is written first (packages/core/src/adapters/cache-adapter.ts:215), so its lastModified is fixed at time T. The tag/path rows are then written afterwards with revalidatedAt = Date.now() (T + δ, δ > 0).
On the next GET, checkTagRevalidation (packages/core/src/adapters/cache-adapter.ts:171-185) calls tagCache.getLastModified(key, T), which queries revalidatedAt > lastModified. The just-written row matches (T + δ > T), so getLastModified returns -1 and the handler answers 404 with x-opennext-cache-tag-status: revalidated. The same query also makes the new isStale path report the entry as stale.
The previous code used revalidatedAt: 1 (see git show c0700800:packages/core/src/adapters/cache-adapter.ts), a value that can never exceed a real lastModified, which is also the convention used by the build-time DynamoDB seed data.
| revalidatedAt: Date.now(), | |
| revalidatedAt: 1, |
Was this helpful? React with 👍 or 👎 to provide feedback.
| const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { | ||
| if (item.expire?.N) { | ||
| return Number.parseInt(item.expire.N) > now; | ||
| } | ||
| return true; | ||
| }); |
There was a problem hiding this comment.
🟡 Pages with a stale-while-revalidate tag are dropped instead of being served stale (DynamoDB SDK tag cache)
Tag records that are still within their stale window are counted as fully revalidated (Number.parseInt(item.expire.N) > now at packages/aws/src/overrides/tagCache/dynamodb.ts:167), so the page is treated as invalid rather than being served while it refreshes in the background.
Impact: Visitors wait for a full regeneration instead of getting the existing page immediately, so the new stale-while-revalidate behaviour never takes effect with this tag cache.
Comparison with the sibling implementation
When revalidateTag is called with durations, the cache handler writes { stale: now, expire: now + expire*1000 } (packages/core/src/adapters/cache-adapter.ts:296-306), i.e. an expire in the future. hasExpiredTag correctly ignores those (expire <= now is false). The nonExpiredRevalidatedTags filter is meant to exclude them too, but expire > now includes exactly the future-expiry (SWR) rows, so getLastModified returns -1 and handleGet answers 404, never reaching the isStale branch.
The lite variant added in the same PR uses a different predicate for this filter (Number.parseInt(item.expire.N) === Number.parseInt(item.revalidatedAt?.N ?? "0") at packages/aws/src/overrides/tagCache/dynamodb-lite.ts:201), which does exclude SWR rows — the two implementations disagree.
| const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { | |
| if (item.expire?.N) { | |
| return Number.parseInt(item.expire.N) > now; | |
| } | |
| return true; | |
| }); | |
| const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { | |
| if (item.expire?.N) { | |
| return Number.parseInt(item.expire.N) <= now; | |
| } | |
| return true; | |
| }); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| const compute = (item: DynamoDBItem): boolean => { | ||
| if (!item) return false; | ||
| if (item.expire?.N) { | ||
| const expiry = Number.parseInt(item.expire.N); | ||
| if (expiry <= now && expiry > (lastModified ?? 0)) return true; | ||
| } | ||
| return Number.parseInt(item.revalidatedAt?.N ?? "0") > (lastModified ?? 0); | ||
| }; |
There was a problem hiding this comment.
🟡 Stale-while-revalidate tags are treated as hard invalidations in next-mode tag caches
A tag that is only supposed to mark content as stale is still reported as fully revalidated (fall-through to revalidatedAt > lastModified at packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts:165), so the cached page is discarded before the staleness check ever runs.
Impact: With next-mode tag caches the new stale-while-revalidate behaviour is unreachable; users always get a blocking regeneration.
Flow that makes isStale unreachable
Writes made with durations set stale/expire in the future while revalidatedAt defaults to Date.now() (packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts:60-67, 217-227). In hasBeenRevalidated, when expire is present but still in the future, the code falls through to revalidatedAt > lastModified, which is true, so it returns true.
handleGet calls checkTagRevalidation first and returns a 404 as soon as it is true (packages/core/src/adapters/cache-adapter.ts:147-163), so the new isStale implementation (packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts:178-207) is never consulted. The equivalent original-mode code deliberately excludes not-yet-expired rows from the revalidated count.
The same problem exists in packages/core/src/overrides/tagCache/fs-dev-nextMode.ts:33-44, whose hasBeenRevalidated ignores expire entirely, making its new isStale dead code.
Prompt for agents
In packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts, the `compute` predicate used by `hasBeenRevalidated` returns true for tags that carry a future `expire` timestamp, because after the expiry check it unconditionally falls back to `revalidatedAt > lastModified`. Tags written with stale/expire durations always have `revalidatedAt = Date.now()`, so they are classified as hard revalidations and the page is dropped instead of being served stale; the new `isStale` method is therefore never reached (the cache handler in packages/core/src/adapters/cache-adapter.ts short-circuits with a 404 when hasBeenRevalidated is true). When an item has an `expire` attribute, only the expiry comparison should decide revalidation; the `revalidatedAt` fallback should apply only to items without `expire`. The same fall-through exists in packages/core/src/overrides/tagCache/fs-dev-nextMode.ts `hasBeenRevalidated`, which ignores `expire` entirely.
Was this helpful? React with 👍 or 👎 to provide feedback.
c070080 to
dc444fc
Compare
18f4542 to
8672cf3
Compare
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.
8672cf3 to
0799572
Compare
Ports opennextjs-aws#1122 and opennextjs-aws#1142 on top of the cache handler function.
staleandexpiredurations:writeTagsaccepts a{ tag, stale, expire }input as well as a plain name,OriginalTagCacheentries gained the same fields, and both tag cache flavours gained an optionalisStale.Cache.revalidateTagstakes the durations. The cache handler turns theexpiredelay into a timestamp, callers pass it through unchanged.updateTags, added in Next.js 16.RequestCacheon the OpenNext request context, so overrides can deduplicate work within a request without touching global state.globalThis.nextVersionis injected in the esbuild banner.Supersedes #30, which was based on the pre-restructure layout (
packages/open-next/).