Skip to content

Cache: port SWR tag revalidation from AWS #1122 / #1142 - #46

Open
conico974 wants to merge 1 commit into
conico/cache-3-consolidatefrom
conico/cache-4-swr-port
Open

Cache: port SWR tag revalidation from AWS #1122 / #1142#46
conico974 wants to merge 1 commit into
conico/cache-3-consolidatefrom
conico/cache-4-swr-port

Conversation

@conico974

@conico974 conico974 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Part 4 of 6 of the cache stack, split out of #31. Review only this PR's diff — it is based on the previous branch.

PR Base
#43 — core: dedicated cache handler function conico/share-build (#35)
#44 — cloudflare: OpenNextCache entrypoint #43
#45 — core: route all caching through the cache override ⚠️ breaking #44
👉 #46 — port SWR tag revalidation from AWS #45
#47 — core: honour Cache-Control on cache entries #46
#48 — cloudflare: per-entrypoint Workers caching #47

Ports opennextjs-aws#1122 and opennextjs-aws#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 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. globalThis.nextVersion is injected in the esbuild banner.
  • DynamoDB, fs-dev and the Cloudflare D1 / KV / sharded DO tag caches updated for the new signatures.

Supersedes #30, which was based on the pre-restructure layout (packages/open-next/).

@pkg-pr-new

pkg-pr-new Bot commented Aug 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/opennextjs/adapters-api/@opennextjs/aws@0799572
npm i https://pkg.pr.new/opennextjs/adapters-api/@opennextjs/cloudflare@0799572
npm i https://pkg.pr.new/opennextjs/adapters-api/@opennextjs/core@0799572

commit: 0799572

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

path: key,
tag,
revalidatedAt: 1,
revalidatedAt: Date.now(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Suggested change
revalidatedAt: Date.now(),
revalidatedAt: 1,
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +165 to +170
const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => {
if (item.expire?.N) {
return Number.parseInt(item.expire.N) > now;
}
return true;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
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;
});
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +159 to +166
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);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@conico974
conico974 force-pushed the conico/cache-3-consolidate branch from c070080 to dc444fc Compare August 16, 2026 13:47
@conico974
conico974 force-pushed the conico/cache-4-swr-port branch from 18f4542 to 8672cf3 Compare August 16, 2026 13:47
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.
@conico974
conico974 force-pushed the conico/cache-4-swr-port branch from 8672cf3 to 0799572 Compare August 16, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant