Skip to content

refactor(router): Vercel store family reads in one read (S10b) - #1254

Merged
ivogt merged 4 commits into
reorg/stackfrom
reorg/s10b-vercel-reads
Oct 23, 2026
Merged

ivogt merged 4 commits into
reorg/stackfrom
reorg/s10b-vercel-reads

Conversation

@ivogt

@ivogt ivogt commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

devcorner: rango-t164 · session rango-s305

VercelCacheStore.get, getItem and getResponse now 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-cache get, the read-error report, miss, envelope decode, corrupt-entry eviction, expiry, the request tag mask, the stale lock claim and the debug event. get and getItem differed only in key family, envelope and read-error result. getResponse added 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:

get(key: string): Promise<CacheGetResult | null | CacheReadError> {
  return this.readRecord(key, SEGMENT_READ);
}
getItem(key: string): Promise<CacheItemResult | null> {
  return this.readRecord(key, ITEM_READ);
}
getResponse(key: string): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
  return this.readRecord(key, RESPONSE_READ);
}

Each descriptor names what differs between the families:

Field SEGMENT_READ ITEM_READ RESPONSE_READ
op get getItem getResponse
family s i r
readError CACHE_READ_ERROR null null
decode asSegmentEnvelope asItemEnvelope asResponseEnvelope
tags env.d.tags env.t env.t
hit { data, shouldRevalidate } { value, handles, shouldRevalidate, tags } { response, shouldRevalidate }; throws on a corrupt body, status or headers

The read-error split stays a named field of each family: CACHE_READ_ERROR for segments, so the PPR replay composition renders uncached instead of substituting its seeded doc record, and null (a miss) for items and responses.

Code-change summary

  • src/cache/vercel/vercel-cache-store.ts
    • Adds the VercelReadFamily interface, the three descriptors and the private readRecord. maskedMiss folds into readRecord.
    • readRecord builds the hit before the stale claim, as getResponse did. 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 carries shouldRevalidate: false until the claim sets it, which keeps each result's key order.
    • get, getItem and getResponse are plain methods that return readRecord's promise, not async wrappers. An async wrapper would settle each read two microtask turns later. The trace counts the turns, and they match base.
    • The four envelope decoders (asSegmentEnvelope, asItemEnvelope, asResponseEnvelope, asShellEnvelope) move from private methods to module functions, unchanged apart from private becoming function. They are pure, and the module-level descriptors reference them. git diff --color-moved shows 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 through readRecord would need four per-read hooks: the debugPerformance timing between decode and the corrupt check, the tag-marker gate (which evicts) in place of the request mask, the conditional claim (claimRevalidation: false and the recapture floor), and the memo write, which needs the raw value and the claim's now. 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-store 82, -versions 11, -start-stamp 9), and 103 of 103 at head.
  • New: 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 :lock key. 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 false on the lock) when the claim is moved before the rebuild.
  • Differential trace, not committed: 7 store configs × 39 cases × 6 read forms = 1,638 scenarios, 13,365 lines, byte-identical between base 4a77cd3 and head (same SHA-256).
    • Store configs: a debug function; no waitUntil; metrics on with a name; debug: true (console); no debug; a throwing debug sink; a throwing waitUntil.
    • Read forms: get, getItem, getResponse, getShell, getShell passive, readShellDocument.
    • Cases: hit, miss, corrupt, malformed and non-object envelopes, legacy object envelopes, bad fields, status and headers (fresh and stale), expiry, failed deletes, the request mask, stale claims (lock present, lock read or write failing), read errors, and an invalidation from another request.
    • Each scenario records every runtime-cache call in order, each debug event as full JSON, console lines, onError categories, the full result, the microtask turns until it settles, and the stored bytes afterwards.
    • The trace catches drift. Two throwaway mutations were each caught: an async wrapper on get changed 273 lines (two extra turns), and claiming before the Response rebuild changed 21 lines (a lock written for an entry then evicted).
  • E2e: the only router e2e suite that drives the Vercel store, 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 --list with the grep matches only it. Also examples/vercel-basic e2e/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 later mask → tags fix is covered by the byte-identical trace.
  • Typecheck (pnpm run typecheck, at the refactor commit), pnpm run test:unit:all (run once, on the refactor's first revision, before the mask → tags review 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).
  • CI lint scripts: 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

  • Lint: oxlint --deny-warnings reports 0 findings and the oxfmt check is clean. Nothing was silenced.
  • Dead code: I searched source, tests, e2e, docs and skills for the removed maskedMiss and the moved decoders. Nothing references them except a test comment naming asShellEnvelope, which still exists. Every import in the file is still used. Nothing was kept on purpose.
  • Tests: the touched suites (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.
  • Code review: the diff was reviewed for reuse, simplification, efficiency, altitude and correctness. No correctness defect was found, and behaviour matches base on every outcome.
    • Fixed:
      • mask, which returned { t, ta }, became tags, with ta read off the envelope: -1 line, and one fewer object per segment read.
      • A doc line that only restated its field was removed.
      • The stale-rebuild test gap above was closed.
    • Left, with reasons:
      • Renaming descriptor fields to match Cloudflare's (debugOp, and family as the family name). In this file family already means the key letter (CacheFamily, toStoreKey, FAMILY_VERSION), so the rename would give the word two meanings here.
      • Deriving op and family with Exclude and Extract. The compiler already checks the literals where they are used (emitDebug, toStoreKey).
      • A precomputed label field, and a private method instead of the per-read corrupt closure. Each costs one string or one closure per read, next to a runtime-cache round trip, and either change would add lines.
      • Passing shouldRevalidate into hit after 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.
      • Moving decodeRaw to module scope. The tag-marker read also uses it, and nothing requires the move. asShellEnvelope moved with the other three decoders so the four stay together.
      • Sharing the read prologue or evict helpers with the shell read. The shell read stays its own path (see the code-change summary), and the shared helpers measured line-neutral or worse.
  • Docs: packages/rangojs-router/docs/internal/feature-map.md gains the Vercel family-read bullet. No other doc names the changed private methods (searched docs/, packages/rangojs-router/docs/, skills and AGENTS.md). The AGENTS.md:28 row is covered in Notes.
  • AGENTS.md: the task-table seam row named readRecord from 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

  • Every Vercel read of the three data families still resolves on the same microtask turn as before, unlike the S10 core, which added one.

After review rango-t164-c2

  • New test "evicts a response past its envelope expiry before the request mask and the rebuild" pins the now > env.e branch, which no committed test reached: both test fakes drop an entry at its platform ttl, which equals the envelope's e. 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.

ivogt added 4 commits October 23, 2026 23:25
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.
@ivogt
ivogt merged commit 54d4359 into reorg/stack Oct 23, 2026
19 checks passed
@ivogt
ivogt deleted the reorg/s10b-vercel-reads branch October 23, 2026 21:17
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.
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