Repository navigation
refactor(router): Vercel store family reads in one read (S10b) - #1254
Merged
Merged
Conversation
VercelCacheStore.get, getItem and getResponse now share one read (readRecord), each family described by a descriptor (SEGMENT_READ, ITEM_READ, RESPONSE_READ) that names its key family, its envelope decoder, its read error (CACHE_READ_ERROR for segments, null for items and responses), the tags the request mask reads and the hit it serves. The hit is built before the stale claim, as getResponse did, so a Response that does not rebuild is evicted with no lock written. The public reads return readRecord's promise directly, so each read settles on the same microtask turn as before. The envelope decoders become module functions, unchanged, and maskedMiss folds into readRecord. The PPR shell read keeps its own path. Error labels, debug events, results and stored bytes are unchanged.
… revalidation The corrupt-response test read a fresh entry, so nothing pinned that readRecord rebuilds the hit before the stale lock claim. The new case reads a stale entry with an undecodable body: it is evicted and no `:lock` key is written. It fails when the claim runs before the rebuild.
… seam The shared cache record core (readRecord) was declined; the Cloudflare and Vercel stores each consolidate their family reads in-store. Finding rango-t164-c1.
…efore the mask and the rebuild Pins the now > env.e branch, which neither test fake reached (both drop an entry at its platform ttl, equal to the envelope's e). Review rango-t164-c2.
49 of 50 tasks
ivogt
added a commit
that referenced
this pull request
Oct 23, 2026
Brings in S10b (#1254), S6 (#1250), S9 (#1244) and S14b (#1252). Conflicts: docs/internal/feature-file-map.md and feature-map.md. Both keep reorg/stack's text plus this branch's two facts: the cache row's CFCacheStore sentence (readL1, readKv, the family descriptors, the read bench) and the "Cloudflare store family reads" bullet, placed after the Vercel store bullets.
13 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
devcorner: rango-t164 · session rango-s305
VercelCacheStore.get,getItemandgetResponsenow run one read,readRecord, driven by a family descriptor (SEGMENT_READ,ITEM_READ,RESPONSE_READ). Non-test source delta: -67 lines (243 insertions, 310 deletions). Behaviour is unchanged: a differential trace of 1,638 read scenarios is byte-identical between base and head. A second commit adds one test for the order the shared read now owns: the hit is rebuilt before the stale claim.Problem
Internal only; nothing a consumer writes or sees changes.
In
packages/rangojs-router/src/cache/vercel/vercel-cache-store.ts, each of the three data-family reads carried its own copy of the same sequence: runtime-cacheget, the read-error report, miss, envelope decode, corrupt-entry eviction, expiry, the request tag mask, the stale lock claim and the debug event.getandgetItemdiffered only in key family, envelope and read-error result.getResponseadded the Response rebuild before the claim. A fix to one copy had to be repeated in the other two.This replaces the declined S10 record core for this store (rango-t139). Like S11 for Cloudflare, it consolidates inside the store, with no shared cross-store module. The memory store is untouched.
Before / after (internal shape)
Before: three methods of 63, 68 and 87 lines, plus
maskedMiss.After:
Each descriptor names what differs between the families:
SEGMENT_READITEM_READRESPONSE_READopgetgetItemgetResponsefamilysirreadErrorCACHE_READ_ERRORnullnulldecodeasSegmentEnvelopeasItemEnvelopeasResponseEnvelopetagsenv.d.tagsenv.tenv.thit{ data, shouldRevalidate }{ value, handles, shouldRevalidate, tags }{ response, shouldRevalidate }; throws on a corrupt body, status or headersThe read-error split stays a named field of each family:
CACHE_READ_ERRORfor segments, so the PPR replay composition renders uncached instead of substituting its seeded doc record, andnull(a miss) for items and responses.Code-change summary
src/cache/vercel/vercel-cache-store.tsVercelReadFamilyinterface, the three descriptors and the privatereadRecord.maskedMissfolds intoreadRecord.readRecordbuilds the hit before the stale claim, asgetResponsedid. A Response that does not rebuild is still evicted as corrupt with no revalidation lock written for it. For segments and items, building first is a pure object literal, so the order changes nothing. The hit carriesshouldRevalidate: falseuntil the claim sets it, which keeps each result's key order.get,getItemandgetResponseare plain methods that returnreadRecord's promise, notasyncwrappers. Anasyncwrapper would settle each read two microtask turns later. The trace counts the turns, and they match base.asSegmentEnvelope,asItemEnvelope,asResponseEnvelope,asShellEnvelope) move from private methods to module functions, unchanged apart fromprivatebecomingfunction. They are pure, and the module-level descriptors reference them.git diff --color-movedshows them as moves.docs/internal/feature-map.md: one bullet on the Vercel family reads, next to the Vercel tags bullet. It is placed so it does not touch the line S11's bullet goes after.The PPR shell read (
readShell) keeps its own path, as S11 kept Cloudflare's. Routing it throughreadRecordwould need four per-read hooks: thedebugPerformancetiming between decode and the corrupt check, the tag-marker gate (which evicts) in place of the request mask, the conditional claim (claimRevalidation: falseand the recapture floor), and the memo write, which needs the raw value and the claim'snow. That is the per-read hook surface the S10 review measured as costing more than it saves.Delta (
git diff --shortstat 4a77cd3 HEAD -- packages/rangojs-router/src ':(exclude)**/__tests__/**' ':(exclude)**/*.test.*' ':(exclude)**/*.rsc-test.*' ':(exclude)**/*.bench.*'): 1 file changed, 243 insertions(+), 310 deletions(-), so -67. The refactor commit changes no test file. The second commit adds one test case (+15 lines).Test plan
src/cache/__tests__/record-read-sequence.test.ts, unchanged: 44 of 44 pass, including the 9 Vercel cases (54 rows: hit, miss, expired, masked, stale, stale read again, corrupt, corrupt read again and read error, for each of the six read forms).src/cache/vercel/__tests__, existing cases unchanged: 102 of 102 pass at the refactor commit (vercel-cache-store82,-versions11,-start-stamp9), and 103 of 103 at head.vercel-cache-store.test.ts"evicts a stale response that does not rebuild without claiming its revalidation". It pins that a stale entry with an undecodable body is evicted and leaves no:lockkey. The existing corrupt-response case only read a fresh entry. The new case passes on base source and on head, and fails (expected true to be falseon the lock) when the claim is moved before the rebuild.waitUntil; metrics on with aname;debug: true(console); no debug; a throwing debug sink; a throwingwaitUntil.get,getItem,getResponse,getShell,getShellpassive,readShellDocument.onErrorcategories, the full result, the microtask turns until it settles, and the stored bytes afterwards.asyncwrapper ongetchanged 273 lines (two extra turns), and claiming before the Response rebuild changed 21 lines (a lock written for an entry then evicted).cache versions across deploys (production)(cache-version.test.ts, a VercelCacheStore over a directory-backed handle), 1 of 1 passed. It is production-only by design (it builds and redeploys four times; the file documents why there is no dev twin), and--listwith the grep matches only it. Alsoexamples/vercel-basice2e/cache-lab.test.ts(use-cache items, PPR shells and tag invalidation through VercelCacheStore), 12 of 12 passed: 6 in dev and 6 in production. CI covers the rest of vercel-basic and the smoke deploy (vercel-basic-e2e,vercel-smoke). Both e2e runs were on the refactor's first revision. The latermask→tagsfix is covered by the byte-identical trace.pnpm run typecheck, at the refactor commit),pnpm run test:unit:all(run once, on the refactor's first revision, before themask→tagsreview fix; the touched suites, the trace and the typecheck were re-run after it; router 9,348 passed, 5 skipped, 6 todo; RSC 1,067 passed, 3 expected fails; cloudflare-basic 90 + 39; vite-rsc-demo 27 + 1; mini 25 + 3; shared-e2e 20), oxlint--deny-warnings(0 findings), oxfmt check (clean).check-e2e-bucketing,check-e2e-parity --strict,check-e2e-console-baseline,check-suspense-contract,check-no-external-store,node --test tools/*.test.mjs(53 of 53),check-doc-links,check-docs-api,check-skills,generate-skills-catalog --check,generate-route-types --check,check-internals(OK; counts unchanged: 18 / 327 / 4 / 1).Practices
--deny-warningsreports 0 findings and the oxfmt check is clean. Nothing was silenced.maskedMissand the moved decoders. Nothing references them except a test comment namingasShellEnvelope, which still exists. Every import in the file is still used. Nothing was kept on purpose.record-read-sequence,cache/vercel/__tests__) pass unchanged. One test was added for the rebuild-before-claim order the shared read now owns, shown red under the mutation. The differential trace, the full unit gate and the targeted e2e are listed above.mask, which returned{ t, ta }, becametags, withtaread off the envelope: -1 line, and one fewer object per segment read.debugOp, andfamilyas the family name). In this filefamilyalready means the key letter (CacheFamily,toStoreKey,FAMILY_VERSION), so the rename would give the word two meanings here.opandfamilywithExcludeandExtract. The compiler already checks the literals where they are used (emitDebug,toStoreKey).labelfield, and a private method instead of the per-readcorruptclosure. Each costs one string or one closure per read, next to a runtime-cache round trip, and either change would add lines.shouldRevalidateintohitafter the claim, instead of setting it on the hit. That needs a second hook (check before, build after). Every descriptor returns a fresh literal, and the interface doc states the contract.decodeRawto module scope. The tag-marker read also uses it, and nothing requires the move.asShellEnvelopemoved with the other three decoders so the four stay together.packages/rangojs-router/docs/internal/feature-map.mdgains the Vercel family-read bullet. No other doc names the changed private methods (searcheddocs/,packages/rangojs-router/docs/, skills andAGENTS.md). TheAGENTS.md:28row is covered in Notes.readRecordfrom the declined shared core; it now names "the store's own family-read helper" (finding rango-t164-c1). The rest of the table only re-aligns under oxfmt.Notes
After review rango-t164-c2
now > env.ebranch, which no committed test reached: both test fakes drop an entry at its platform ttl, which equals the envelope'se. It passes on head and on the base store, and fails with the expiry heal dropped (1 failed, 83 passed), as the review also showed for the expiry check moved after the mask or after the rebuild.