Skip to content

fix(cache): persist prerender resume data - #2849

Draft
james-elicx wants to merge 6 commits into
mainfrom
codex/fix-resume-data-cache
Draft

fix(cache): persist prerender resume data#2849
james-elicx wants to merge 6 commits into
mainfrom
codex/fix-resume-data-cache

Conversation

@james-elicx

@james-elicx james-elicx commented Aug 10, 2026

Copy link
Copy Markdown
Member

Summary

  • retain cache reads and writes produced during prerendering and serialize them into build output
  • restore prerendered cache entries at runtime, including cache metadata and Cloudflare KV population
  • preserve stale, hard-invalidation, and concurrent profiled-tag semantics across Memory and KV handlers

Validation

  • targeted vp check on all 20 changed files
  • 326 focused cache, prerender, fetch, and feature assertions on the final tree
  • targeted Next.js E2E: test/e2e/app-dir/resume-data-cache/resume-data-cache.test.ts — 5/5 passed on committed head 3357bd2b2
  • final independent review: no findings

Failure source: Actions run 31290819291.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2849
npm i https://pkg.pr.new/create-vinext-app@2849
npm i https://pkg.pr.new/@vinext/types@2849
npm i https://pkg.pr.new/vinext@2849

commit: 19af969

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(cache): persist prerender resume data

I reviewed the full diff (19 files) and ran the affected targeted suites locally. All pass:

  • tests/prerender-data-cache.test.ts + tests/kv-cache-handler.test.ts — 90 passed
  • tests/shims.test.ts + tests/prerender-server-pool.test.ts + tests/prerender-kv-populate.test.ts — 1301 passed
  • tests/fetch-cache.test.ts + tests/isr-cache.test.ts — 211 passed
  • vp check on the three core changed files — clean (format, lint, types)

Assessment

The change is well-structured and matches the layering guidance in AGENTS.md: the cross-process build cache lives in a typed server/prerender-data-cache.ts module rather than in a generated entry, and the generated prerender-server-entry.ts stays thin (just picks the handler based on VINEXT_PRERENDER_DATA_CACHE_DIR). Path wiring is consistent end-to-end — outDir in prerenderApp (dist/server/prerendered-routes) equals path.join(serverDir, "prerendered-routes") used by both seed-cache.ts and prerender-kv-populate.ts, so build-time writes and runtime/KV reads target the same directory.

Things I specifically verified and consider correct:

  • Single-flight coordination (.pending lock) and write serialization (.write-lock) use openSync(..., "wx") create-exclusive semantics with stale-lock recovery via mtime + PENDING_ENTRY_TIMEOUT_MS. Crash paths are covered: refreshAndStore/unstable_cache/fetch-cache all call releasePendingSet in their error branches, and set releases in finally.
  • Timestamp monotonicity — both MemoryCacheHandler and KVCacheHandler now use _nextTimestamp()/_currentTimestamp() so a same-millisecond set after revalidateTag is correctly treated as newer (tested).
  • Tag revalidation model — the shift from a bare timestamp to { expiredAt, staleAt, expireAt } is applied consistently across the memory and KV handlers, with _parseTagRevalidation retaining backward compatibility for legacy bare-timestamp markers (including the NaN "always expired" case).
  • SWR behavior change in registerCachedFunction: the hit gate moved from cacheState !== "stale" to cacheState !== "expired", so stale use cache entries are now served at runtime with a background waitUntil revalidation, while VINEXT_PRERENDER=1 forces a foreground refresh so static generation never bakes a stale value. This matches Next.js's "stale entries are foreground misses while prerendering; SWR is a runtime-only optimization" semantics and is covered by the new shims tests.
  • revalidate: false round-trip through the serializing KV adapter no longer sets a TTL and no longer coerces the claim — matching unstable_cache "cache indefinitely" semantics (tested).

Notes (non-blocking)

  1. KV revalidateTag read-modify-write is not atomic. revalidateTag now does kv.get(tagKey) then kv.put(tagKey, ...) to merge staleAt/expiredAt onto an existing marker. Cloudflare KV has no CAS, so two concurrent invalidations of the same tag (e.g. a hard revalidateTag(tag) racing a profiled revalidateTag(tag, { expire })) can lose one side's field. The in-isolate _lastTimestamp monotonicity that protects the memory handler does not hold across isolates. This is an inherent KV limitation rather than a regression, but worth a code comment near the merge so it isn't mistaken for a strong guarantee.

  2. Read-path re-persist write amplification during prerender. In PrerenderDataCacheHandler.get, every memory-hit for a tagged FETCH re-serializes and re-writes the entry file (under the write-lock) to union newly-requested tags. Correct, but on large apps with many shared cached fetches this multiplies build-time file writes. If prerender build time regresses, this is the first place to look — a dirty check (skip the write when the persisted tag set already covers the requested tags) would cut it.

  3. Consider a brief comment on why PrerenderDataCacheHandler.set calls this.memory.get(key) (no ctx) before persisting — a reader might expect the just-set value and not realize the get could return null if a tag was revalidated in the same worker.

No correctness blockers found. The concurrency model, error/lock-release paths, and dev/build/runtime/KV parity all look sound and are backed by targeted tests.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 19af969 against base d558d62 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 135.2 KB 135.2 KB ⚫ -0.0%
Client entry size (gzip) vinext 122.7 KB 122.7 KB ⚫ +0.0%
Dev server cold start vinext 3.03 s 3.03 s ⚫ +0.2%
Production build time vinext 3.11 s 3.10 s ⚫ -0.5%
RSC entry closure size (gzip) vinext 114.2 KB 114.7 KB ⚫ +0.4%
Server bundle size (gzip) vinext 193.0 KB 194.3 KB ⚫ +0.7%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2849.

Summary

I reviewed all 19 changed files in PR #2849 (fix(cache): persist prerender resume data) and ran the affected test suites locally — everything passes (90 cache tests, 1301 shims/prerender tests, 211 fetch/isr tests, and vp check on the core files).

No correctness blockers found. The change is well-architected:

  • Build cache logic lives in a typed server/prerender-data-cache.ts module (not a generated entry), matching repo layering guidance
  • Path wiring is consistent: outDir = serverDir/prerendered-routes across build write, runtime seed, and KV populate
  • Single-flight (.pending) and write-serialization (.write-lock) locks use create-exclusive semantics with stale-lock recovery; all error paths release locks (releasePendingSet)
  • Timestamp monotonicity fixes correctly handle same-millisecond set-after-revalidateTag
  • The {expiredAt, staleAt, expireAt} tag model is applied consistently in both memory and KV handlers, with legacy bare-timestamp backward compat
  • The SWR behavior change (serve stale + background revalidate at runtime, but foreground-refresh under VINEXT_PRERENDER=1) matches Next.js semantics and is tested

Non-blocking notes I raised:

  1. KV revalidateTag is now a non-atomic read-modify-write (no CAS in KV) — inherent limitation, worth a comment
  2. Read-path re-persist in get causes build-time write amplification for shared tagged fetches — a dirty check would help if build time regresses
  3. A clarifying comment on why set re-reads via memory.get(key) before persisting

github run

@james-elicx
james-elicx force-pushed the codex/fix-resume-data-cache branch from 777407d to 3357bd2 Compare August 10, 2026 02:49
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(cache): persist prerender resume data

Reviewed the full 20-file diff and re-ran the affected targeted suites locally — all green:

  • tests/prerender-data-cache.test.ts + tests/kv-cache-handler.test.ts — 91 passed
  • tests/shims.test.ts + tests/prerender-server-pool.test.ts + tests/prerender-kv-populate.test.ts — 1301 passed
  • tests/fetch-cache.test.ts + tests/isr-cache.test.ts — 211 passed
  • vp check on the four core changed files — clean

No correctness blockers found. The layering follows AGENTS.md (cross-process build cache lives in a typed server/prerender-data-cache.ts, generated prerender-server-entry.ts stays thin), path wiring is consistent end-to-end (outDir = serverDir/prerendered-routes across build write, runtime seed, and KV populate), and all error/crash paths release the single-flight .pending and serialization .write-lock locks with openSync(..., "wx") create-exclusive semantics + stale-lock recovery.

Things I specifically verified

  • Hard vs profiled tag races on KV are now separated by keyspace. The prior review flagged revalidateTag as a non-atomic read-modify-write that could lose a hard-vs-profiled race. In this diff, hard invalidations write __tag: and profiled ones write __tag_profile: (separate keys, both read in _getTagRevalidation), so a hard revalidateTag(tag) and a profiled revalidateTag(tag, { expire }) no longer clobber each other. Only two concurrent profiled invalidations of the same tag still race, which is a genuinely inherent KV limitation. Covered by the "does not weaken a hard invalidation with a later profiled revalidation" test.
  • Memory size accounting stays consistent through the new read-path tag merge. MemoryCacheHandler.get now unions requested tags onto a stored FETCH entry (growing currentMemoryCacheSize) before the tag-expiry delete check. This is safe because deleteEntry recomputes estimateEntrySize(existing) from the already-mutated entry, so the grown size added on the merge is exactly the size subtracted on delete. Same for the softTag-null and stale/expired return paths where the entry is retained.
  • Timestamp monotonicity — both handlers use nextTimestamp()/currentTimestamp(), so a same-millisecond set after revalidateTag is correctly treated as newer (tested in both memory and KV).
  • revalidate: false round-trips through the KV serializer without a TTL and without being coerced, matching unstable_cache "cache indefinitely". validateCacheEntry now accepts cacheControl.revalidate === false, and buildPrerenderKVPairs only sets expiration_ttl for numeric revalidate.
  • SWR behavior change in registerCachedFunction: the hit gate moved from !== "stale" to !== "expired", so stale use cache entries are served at runtime with a waitUntil background revalidation, while VINEXT_PRERENDER=1 forces a foreground refresh so static generation never bakes a stale value. The build-time foreground path means the background refreshAndStore(false) never runs against the PrerenderDataCacheHandler, so there's no interaction between skipPropagation and the build cache.
  • requestMemoKey — the per-registration (): void => {} closure used as a WeakMap key into requestCache is correct: it's stable across calls of the same cached function within a request and gives each function its own resume map.

Notes (non-blocking)

  1. PrerenderDataCacheHandler.get re-persists on every tagged FETCH memory hit. Lines 157-164 and 195-202 re-serialize + re-write the entry file (under .write-lock) to union newly-requested tags on every hit, not just when the persisted tag set actually changed. On large apps with many shared cached fetches this multiplies build-time file writes. A dirty check (skip the write when the persisted tags already cover the requested tags) would cut it — first place to look if prerender build time regresses.
  2. PrerenderDataCacheHandler.set calls this.memory.get(key) with no ctx before persisting (line 249). Worth a one-line comment: the re-read can return null if the just-set entry was tag-revalidated in the same worker, in which case nothing is persisted — which is intentional but surprising to a reader who expects the value just written.
  3. Two concurrent profiled revalidateTag(tag, { expire }) on KV can still lose a field (no CAS on __tag_profile:). Inherent KV limitation now that the hard/profiled keyspaces are split; a short comment near the __tag_profile: merge would prevent it being mistaken for a strong guarantee.
  4. Background revalidation floats when not inside a unified scope (scheduleUseCacheBackgroundRevalidation only registers waitUntil under isInsideUnifiedScope()). The promise has a .catch, so no unhandled rejection, and it's acceptable SWR degradation — noting only for awareness.

The concurrency model, lock-release paths, dev/build/runtime/KV parity, and timestamp/tag semantics all look sound and are backed by targeted tests.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2849.

Summary

I reviewed all 20 changed files in PR #2849 (fix(cache): persist prerender resume data) and re-ran the affected suites locally — everything passes (91 cache tests, 1301 shims/prerender tests, 211 fetch/isr tests, vp check clean on the four core files).

No correctness blockers found. Key verifications:

  • Hard vs profiled tag races are actually resolved by keyspace separation — the prior bot review flagged the KV revalidateTag read-modify-write as lossy across hard/profiled invalidations, but this diff writes hard markers to __tag: and profiled markers to __tag_profile: (separate keys, both read), so those two no longer clobber. Only concurrent profiled-with-profiled still races (inherent KV, no CAS).
  • Memory size accounting is consistent through the new read-path tag merge, because deleteEntry recomputes size from the already-mutated entry — I traced this specifically since a growth-before-delete pattern is a common accounting-drift bug.
  • Timestamp monotonicity, revalidate: false round-trip (no TTL, not coerced), the SWR gate change (!== "stale"!== "expired" with prerender foreground-refresh), and the requestMemoKey WeakMap key are all correct.

Non-blocking notes raised:

  1. Read-path re-persist on every tagged FETCH hit → build-time write amplification (dirty check would help).
  2. Clarifying comment on why set re-reads via memory.get(key) (can return null).
  3. Comment near __tag_profile: merge that profiled-vs-profiled is still non-atomic.
  4. Background revalidation floats (with .catch) when not in a unified scope — acceptable, noted for awareness.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 10, 2026 02:56
@james-elicx

Copy link
Copy Markdown
Member Author

Mapping note for the cache-components backlog from Actions run 31290819291: I think this PR fixes all three reported test/e2e/app-dir/resume-data-cache/resume-data-cache.test.ts failures (use-cache consistency, fetch-cache consistency, and server-action rerenders). The targeted Next.js wrapper passes 5/5.

…-cache

# Conflicts:
#	packages/vinext/src/build/prerender.ts
#	tests/prerender-server-pool.test.ts
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(cache): persist prerender resume data

Reviewed the full 22-file diff and re-ran the affected suites locally — all green:

  • tests/prerender-data-cache.test.ts + tests/kv-cache-handler.test.ts — 103 passed
  • tests/shims.test.ts + tests/fetch-cache.test.ts + tests/isr-cache.test.ts + tests/prerender-kv-populate.test.ts + tests/prerender-server-pool.test.ts — 1516 passed
  • vp check on the four core changed files — clean (format, lint, types)

No correctness blockers found. The layering follows AGENTS.md (cross-process build cache is a typed server/prerender-data-cache.ts module; the generated prerender-server-entry.ts stays thin, just choosing the handler from VINEXT_PRERENDER_DATA_CACHE_DIR), path wiring is consistent (outDir = serverDir/prerendered-routes across build write, runtime seed, and KV populate), and all crash paths release the single-flight .pending and serialization .write-lock locks (openSync(..., "wx") create-exclusive + mtime-based stale recovery).

Things I specifically verified

  • Cloudflare KV runtime is not re-seeded per request. I traced seedPrerenderDataCache(prerenderDir, getDataCacheHandler()) in seed-cache.ts — it runs only from startProdServer (Node prod server startup), where the default handler is MemoryCacheHandler. The Cloudflare fetch-handler.ts worker entry does not call the seeder; KV is populated once at deploy time via buildPrerenderKVPairs. So the else branch in seedPrerenderDataCache (which calls handler.set because KVCacheHandler has no seed) does not create a per-cold-start KV write storm. This was my top concern and it checks out.
  • Hard vs profiled tag races on KV are separated by keyspace. Hard invalidations write __tag: and profiled ones __tag_profile: (both read in _getTagRevalidation, merged in revalidateTag's _tagCache update), so a hard revalidateTag(tag) and a profiled revalidateTag(tag, { expire }) no longer clobber each other. Covered by the new "does not weaken a hard invalidation..." / "keeps concurrent hard and profiled invalidations independently composable" tests.
  • Memory size accounting stays consistent through the new read-path tag merge. MemoryCacheHandler.get grows currentMemoryCacheSize when unioning requested tags onto a FETCH entry before the tag-expiry delete; deleteEntry recomputes estimateEntrySize(existing) from the already-mutated entry, so the grown size is exactly the size subtracted on delete. evictLeastRecentlyUsed is now also called from get, but touchEntry makes the just-read entry MRU so it isn't self-evicted.
  • Timestamp monotonicity — both handlers use nextTimestamp()/currentTimestamp(); seed bumps lastTimestamp to metadata.lastModified so a later same-ms set is still treated as newer. Tested in both memory and KV.
  • revalidate: false round-trips through the KV serializer without a TTL (expiration_ttl only for numeric revalidate) and without coercion; validateCacheEntry accepts cacheControl.revalidate === false, and buildPrerenderKVPairs mirrors this.
  • commit disposition in app-rsc-handler.ts (!privateCacheUsed || !hasExplicitNonCacheableResponsePolicy(headers)) matches the private-cache-discard test at prerender-data-cache.test.ts:97 and the cacheable-commit test at :139.
  • Error/lock-release parity across refreshAndStore (cache-runtime), refreshUnstableCacheResult (cache.ts), and createPatchedFetch (fetch-cache.ts) — every throw/no-value branch calls releasePendingSet(cacheKey).

Notes (non-blocking)

  1. observeEntry re-persists on every tagged FETCH memory hit during prerender. When a tracker exists (the normal in-request prerender path), persistEntry always runs a full read-modify-write under .write-lock — even in the existing.version === incomingVersion.version branch where the persisted tag set is unchanged. A dirty check (skip the write when the persisted tags already cover the incoming tags) would cut build-time file writes on apps with many shared cached fetches. First place to look if prerender build time regresses.
  2. PrerenderDataCacheHandler.set re-reads via this.memory.get(key) (no ctx) before persisting. Worth a one-line comment: the re-read can return null if the just-set entry was tag-revalidated in the same worker, in which case nothing is persisted — intentional but surprising.
  3. Two concurrent profiled revalidateTag(tag, { expire }) on KV can still lose a field (no CAS on __tag_profile:). Inherent KV limitation now that hard/profiled keyspaces are split; a short comment near the merge would prevent it being read as a strong guarantee.
  4. entryVersion dereferences entry.value.data.headers without isPersistedFetchEntry guarding data.headers. Safe for vinext-constructed FETCH values (always have headers), but a malformed persisted FETCH would throw inside the readEntrypersistedVersion path rather than being treated as a corrupt-entry miss. Defensive only.

The concurrency model, lock-release paths, dev/build/runtime/KV parity, and timestamp/tag semantics all look sound and are backed by targeted tests.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2849.

Summary

I reviewed all 22 changed files in PR #2849 (fix(cache): persist prerender resume data) and re-ran the affected suites locally — everything passes (103 cache/KV tests, 1516 shims/fetch/isr/prerender tests, vp check clean on the four core files).

No correctness blockers found. The change is well-architected and matches the repo's layering guidance.

Key verifications:

  • Cloudflare KV is not re-seeded per request (my top concern). seedPrerenderDataCache runs only from the Node startProdServer startup where the handler is Memory; the Cloudflare worker entry never calls it, and KV is populated once at deploy time via buildPrerenderKVPairs. No cold-start KV write storm.
  • Hard vs profiled tag races are resolved by splitting __tag: / __tag_profile: keyspaces.
  • Memory size accounting stays consistent through the new read-path tag merge (grow-before-delete recomputes from the mutated entry).
  • Timestamp monotonicity, revalidate: false round-trip, the commit disposition logic, and error/lock-release parity across the three refresh paths are all correct.

Non-blocking notes raised:

  1. observeEntry re-persists on every tagged FETCH hit during prerender even when tags are unchanged — a dirty check would cut build-time write amplification.
  2. set re-reads via memory.get(key) (can return null) before persisting — worth a comment.
  3. Concurrent profiled revalidateTag(..., {expire}) on KV can still lose a field (inherent, no CAS).
  4. entryVersion dereferences data.headers without guarding — defensive-only, safe for vinext-constructed values.

github run

@james-elicx
james-elicx marked this pull request as draft August 10, 2026 21:03
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review: fix(cache): persist prerender resume data

Reviewed the full 22-file diff independently and re-ran the affected suites locally — all green:

  • tests/prerender-data-cache.test.ts + tests/kv-cache-handler.test.ts — 103 passed
  • tests/shims.test.ts + tests/fetch-cache.test.ts + tests/isr-cache.test.ts — 1500 passed
  • vp check on the four core files (prerender-data-cache.ts, cache-handler.ts, cache-runtime.ts, kv-data-adapter.runtime.ts) — clean (format, lint, types)

No correctness blockers found. The change is well-architected and follows AGENTS.md layering (cross-process build cache is a typed server/prerender-data-cache.ts; the generated prerender-server-entry.ts stays thin, choosing the handler from VINEXT_PRERENDER_DATA_CACHE_DIR).

Things I independently verified

  • Path wiring is consistent end-to-end. outDir = path.join(root, "dist", "server", "prerendered-routes") in run-prerender.ts:214, which equals path.join(serverDir, "prerendered-routes") in seed-cache.ts and the dir passed to buildPrerenderKVPairs. Build write, runtime seed, and KV populate all target the same directory.
  • KV data-cache keys don't collide with route keys. Data entries use keySpace.entryKey(entry.key) (fetch cache key) while routes use keySpace.entryKey(cachePathname) — distinct logical keys in the same keyspace, no overwrite.
  • Memory get FETCH tag-merge size accounting is consistent. estimateIncrementalCacheValueSize for FETCH only measures value.data (not tags), so the entry.value = { ...entry.value, tags } spread doesn't change value size; only entry.tags sum grows, and deleteEntry recomputes from the mutated entry. evictLeastRecentlyUsed() added to get is safe because touchEntry makes the just-read entry MRU.
  • commit disposition in app-rsc-handler.ts is only set in the success path; a thrown error leaves commit: null, and the finalizer treats commit === true strictly, so failed renders correctly drop provisional versions.
  • Error/lock-release parity across refreshAndStore (cache-runtime), refreshUnstableCacheResult (cache.ts), and createPatchedFetch (fetch-cache.ts) — every throw/no-value branch calls releasePendingSet(cacheKey); set releases in finally.
  • requestMemoKey is a per-registration closure (stable across calls of the same cached function), correctly giving each function its own resume map keyed by cacheKey.

Notes (non-blocking)

  1. KV get new-tag detection uses a length comparison. kv-data-adapter.runtime.ts:280 gates the tag surface on hardTags.length !== entry.tags.length. Because hardTags = validUniqueTags([...entry.tags, ...requestedTags]), if entry.tags itself contains dupes/invalid tags the lengths differ even with no new requested tag — benign (it just normalizes restoredValue.tags), but the memory handler's requestedTags.some((tag) => !entry.tags.includes(tag)) is the more precise predicate. Worth aligning for clarity.
  2. observeEntry re-persists on every tagged FETCH memory hit during prerender (already raised in prior reviews). A dirty check (skip the write when the persisted tag set already covers the incoming tags) would cut build-time write amplification on apps with many shared cached fetches.
  3. Two concurrent profiled revalidateTag(tag, { expire }) on KV can still lose a field (no CAS on __tag_profile:). Inherent KV limitation now that hard/profiled keyspaces are split; a short comment near the merge would prevent it being read as a strong guarantee.

The concurrency model, lock-release paths, dev/build/runtime/KV parity, timestamp monotonicity, and hard/profiled tag semantics all look sound and are backed by targeted tests.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2849.

Summary

I independently reviewed all 22 changed files in PR #2849 (fix(cache): persist prerender resume data) and re-ran the affected suites locally — everything passes (103 cache/KV tests, 1500 shims/fetch/isr tests, vp check clean on the four core files).

No correctness blockers found. The change is well-architected and follows AGENTS.md layering.

Things I verified independently:

  • Path wiring is consistent end-to-end — outDir = dist/server/prerendered-routes across build write (run-prerender.ts:214), runtime seed (seed-cache.ts), and KV populate.
  • KV data-cache keys don't collide with route keys (fetch key vs pathname, both distinct logical keys).
  • Memory get FETCH tag-merge size accounting stays consistent (FETCH size only measures value.data, and deleteEntry recomputes from the mutated entry; the new evictLeastRecentlyUsed() in get is safe because touchEntry makes the entry MRU).
  • commit disposition correctly drops provisional versions on failed renders (error leaves commit: null).
  • Error/lock-release parity across all three refresh paths — every throw/no-value branch calls releasePendingSet.

Non-blocking notes raised:

  1. New KV finding: kv-data-adapter.runtime.ts:280 detects new tags via hardTags.length !== entry.tags.length, which is imprecise if stored tags contain dupes/invalid entries — benign, but the memory handler's .some(tag => !entry.tags.includes(tag)) predicate is cleaner and worth aligning.
  2. observeEntry build-time write amplification (dirty check would help).
  3. Concurrent profiled revalidateTag(..., {expire}) on KV can still lose a field (inherent, no CAS).

github run

…-cache

# Conflicts:
#	packages/vinext/src/server/seed-cache.ts
#	tests/prerender-kv-populate.test.ts
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(cache): persist prerender resume data

Independent re-review of the full 22-file diff. Re-ran the affected suites locally — all green:

  • tests/prerender-data-cache.test.ts — 26 passed
  • tests/kv-cache-handler.test.ts — 77 passed
  • tests/shims.test.ts -t "use cache" — 59 passed
  • tests/fetch-cache.test.ts + tests/isr-cache.test.ts — 211 passed
  • vp check on the four core files — clean (format, lint, types)

No correctness blockers found. The change follows AGENTS.md layering (cross-process build cache is a typed server/prerender-data-cache.ts; the generated prerender-server-entry.ts stays thin, selecting the handler from VINEXT_PRERENDER_DATA_CACHE_DIR).

Things I verified independently

  • Path wiring is consistent end-to-end. outDir for non-export builds resolves to dist/server/prerendered-routes (run-prerender.ts:214), which equals path.join(serverDir, "prerendered-routes") in seed-cache.ts:122 and the prerenderDir passed to buildPrerenderKVPairs. The data cache lives under .vinext-resume-data-cache inside that dir across build write, runtime seed, and KV populate. resetPrerenderDataCache(outDir) runs before the pool starts (prerender.ts:1056 vs pool at :1745), so no reset/pool race.
  • commit disposition (app-rsc-handler.ts:1767) is only set inside VINEXT_PRERENDER=1, and a thrown render leaves commit: null (the earlier catch re-throws), so the finalizer — which treats commit === true strictly — correctly drops provisional versions on failed renders. prerenderDataCacheState.finalizers/commit are shared by reference across nested unified scopes via the documented shallow clone.
  • Runtime SWR change is safe. The use cache hit gate moved from !== "stale" to !== "expired", so stale entries now serve immediately + background-revalidate at runtime, but foreground-refresh under VINEXT_PRERENDER=1 (cache-runtime.ts:864). scheduleUseCacheBackgroundRevalidation dedupes by cacheKey in a global map so concurrent stale hits schedule only one refresh (asserted by waitUntilPromises length 1 in the new shims test); the background refreshAndStore(false) runs with skipPropagation: true and .catch. requestResumeCache gives intra-request read consistency.
  • Memory tag-model refactor is size-consistent. The new read-path FETCH tag merge (cache-handler.ts:268) grows currentMemoryCacheSize before the tag-expiry delete; deleteEntry recomputes from the mutated entry, and touchEntry makes the just-read entry MRU so the new evictLeastRecentlyUsed() in get can't self-evict. revalidateTag merges ...existing so a profiled revalidation never drops a prior hard expiredAt.
  • Hard vs profiled tag races are separated by keyspace (__tag: vs __tag_profile:, both read in _getTagRevalidation); kv-key.ts correctly widens the app-prefix budget check to the longer PROFILE_TAG_PREFIX.
  • revalidate: false round-trips without a TTL (buildPrerenderKVPairs only sets expiration_ttl for numeric revalidate; validateCacheEntry accepts revalidate === false).
  • Error/lock-release parity across refreshAndStore (cache-runtime), createPatchedFetch (fetch-cache), and set (finally) — every throw / no-value branch calls releasePendingSet.

Notes (non-blocking)

  1. Build-time write amplification in observeEntry. Every tagged FETCH memory hit during prerender runs a full read-modify-write under .write-lock, even when the persisted tag set already covers the incoming tags. A dirty check would cut file writes on apps with many shared cached fetches — first place to look if prerender build time regresses.
  2. KV new-tag detection is imprecise. kv-data-adapter.runtime.ts:280 gates on hardTags.length !== entry.tags.length; if a stored entry's tags contains dupes/invalid entries the lengths differ with no genuinely new requested tag. Benign (it only re-normalizes restoredValue.tags), but the memory handler's requestedTags.some((tag) => !entry.tags.includes(tag)) predicate is cleaner and worth aligning.
  3. Concurrent profiled revalidateTag(tag, { expire }) on KV can still lose a field (no CAS on __tag_profile:, now that hard/profiled keyspaces are split). Inherent KV limitation; a short comment near the merge would prevent it being read as a strong guarantee.
  4. set re-reads via this.memory.get(key) (no ctx) before persisting (prerender-data-cache.ts:712) — can return null if the just-set entry was tag-revalidated in the same worker, in which case nothing is persisted. Intentional but surprising; worth a one-line comment.

The concurrency model, lock-release paths, dev/build/runtime/KV parity, timestamp monotonicity, and hard/profiled tag semantics are sound and backed by targeted tests.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2849.

Summary

I independently re-reviewed all 22 changed files in PR #2849 (fix(cache): persist prerender resume data) and re-ran the affected suites locally — everything passes (26 prerender-data-cache, 77 KV, 59 use-cache shims, 211 fetch/isr tests; vp check clean on the four core files).

No correctness blockers found. The change is well-architected and follows AGENTS.md layering.

Things I verified independently:

  • Path wiring is consistent end-to-end (dist/server/prerendered-routes across build write, runtime seed, and KV populate), and resetPrerenderDataCache runs before the prerender pool starts (no reset/pool race).
  • commit disposition correctly drops provisional versions on failed renders and is shared by reference across nested unified scopes.
  • Runtime SWR change (!== "stale"!== "expired") serves stale + background-revalidates at runtime, foreground-refreshes under prerender, and dedupes background revalidations by cache key.
  • Memory tag-model refactor stays size-consistent through the read-path tag merge, and revalidateTag merges ...existing so a profiled revalidation never drops a hard expiredAt.
  • Hard vs profiled tag keyspace split and the widened app-prefix budget check in kv-key.ts.
  • revalidate: false round-trip and error/lock-release parity across all three refresh paths.

Non-blocking notes raised: build-time write amplification in observeEntry, imprecise KV new-tag detection (length vs .some), concurrent profiled-tag KV races (no CAS), and a clarifying comment for set's re-read.

github run

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