Repository navigation
feat(net): js/net caches into a per-broadcast byte pool ported from Rust - #8177
Conversation
Quest-Claim: 60c78b3f0d59a6ba
Port Rust's `cache::Pool` to `js/net/src/cache.ts`. Every cached group bills its frames, a fixed per-group overhead, and each subscriber mirror it holds to its track's account, and every track of a broadcast shares one pool, defaulting as Rust's `broadcast::Info` does: no byte capacity and a 30 s idle window. Over capacity, a track accrues eviction debt as it writes and pays it by evicting its own oldest groups with Evicted, never the live edge, ordered by the mean access across the pool. Reads count as accesses. The pool's sweep, every half window, replaces each track's own prune timer. Finishes quest/m1/js-cache-pool.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…expiry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The track's cache was an array searched and spliced per eviction, so a sweep expiring N groups shifted the retained tail N times. An insertion-ordered set removes each in constant time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust stamps a cache access when a subscriber takes a group, not only when it reads a frame. Mirror it from the guard every handed-out group passes through, and port `delivery_restarts_the_expiry_clock`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 10 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head b5f1e11.
[P2] Keep finished readers accounted for when shedding frames (js/net/src/group.ts:703–713). Finished groups can now shed every frame, but finish removes their readers from #mirrors, and readers created after finish never enter that map. #shedMirrors then subtracts payload bytes from the pool without releasing the unread copies in those readers' queues or ending them with Evicted. With a small configured capacity, retain an unread mirror of a finished live-edge group and append another group: debt shedding can credit its payload while that mirror still retains and reads it. Propagate shedding through the retained track-level mirrors, or otherwise keep those bytes charged until actually released, and cover this finished-group case.
[P2] Avoid linear array shifts in the bounded eviction scan (js/net/src/cache.ts:337–355). #order.shift()/unshift() move the retained array tail on each candidate. Under byte pressure, even a four-candidate scan costs O(retained groups), and draining a large backlog can become quadratic. The Set change fixes idle removal but not this debt-payment path. Use a deque or head-index queue with amortized compaction and exercise a capacity-limited pool in the scaling benchmark.
Direction: the shared pool/account/charge split is sound, but byte-pressure reclamation should release what it credits and keep scan costs bounded. These findings concern explicitly capacity-limited internal pools; the default remains unbounded. Verification limits: GitHub-only static review of all 19 changed files, surrounding JS group/track code and the Rust cache implementation. No tests, benchmarks or memory measurements run.
There was a problem hiding this comment.
ℹ️ Minor suggestions only — all three rough edges live in the new byte-budget paths, which are currently reachable only with a configured capacity.
Reviewed changes
- Byte pool port (
js/net/src/cache.ts):Pool/Track/Chargemirror Rust'scachemodule plus the debt/eviction and expiry walks frommodel/track.rs, including the access-mean ordering, stale doubling, carried debt, and the bounded scan. - Pool wiring:
BroadcastStateowns one pool andcreateProducerputs every track of a broadcast on it (broadcast.ts); a track with no broadcast keeps its own. - Group billing:
group.tsbills frame writes, compression repacking, and tail trims to the group'sCharge, sheds fronts on demand, and refreshes access on frame reads; mirrors share the source charge. - Track cache:
#cachebecame an insertion-orderedSet,#pruneand its timer left for the account's bounded scan plus the pool's half-window sweep, and byte eviction abortsEvictedwhile idle expiry keepsTooFarBehind. - Delivery refresh: handing a group to a subscriber now restarts its idle window.
- Quest cleanup: the shipped quest is deleted from
quest/m1/, anddrop-set.mdpoints atcache.ts.
Both P2 findings in the existing OpenAI review still stand at this head; I reproduced the finished-group shed one (a shed with frames=1, closed=null credited ~10 KiB while the reader still returned its 10,240 bytes).
ℹ️ The byte budget has no way to be enabled
Every production pool is created with an expiry and no capacity, and Pool.resize has no caller outside tests, so accrue, #payDebt, and #shed never run in production. The only live behavior change is the sweep-driven idle expiry; the byte half is covered only by cache.test.ts. The body records the missing setter as deliberate, but the deleted quest's Goal and the drop-set follow-up both assume a pool that can actually be bounded, and nothing in the tree owns that wiring yet.
Technical details
# The byte budget has no way to be enabled
## Affected sites
- `js/net/src/broadcast.ts:60` — every broadcast pool is created with `{ expiry }` only
- `js/net/src/track.ts:1071` — a standalone track's pool likewise
- `js/net/src/cache.ts:107` — `Pool.resize` is package-internal and called only from tests
## Required outcome
- Confirm which follow-up lets a consumer set a capacity, and that it is tracked, before this becomes the base the drop-set billing counts on.
- If the eviction half is meant to land with that wiring, consider splitting it out: today the debt path's cost has no guard either, since `js/net/bench/track.ts` uses the default unbounded pool and never reaches `#payDebt`.
## Open questions for the human
- Is the capacity source an origin/broadcast config in a later quest, or should `resize` be reachable now?deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
…tant time An open group keeps its newest committed frame behind one still being written, as Rust's `GroupState::shed` does. Shedding a finished group ends the copies its track holds that had not taken the shed frames, since a finished group no longer tees into them. The account's eviction order reads from a head index, because `Array.shift` moves the whole array in V8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on order Each cached group holds a slot in its account's order, which removal empties, so neither the slots behind the head index nor the dead hints a scan has yet to reach pin an expired group's frames. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JSC scans the stack conservatively, so whether a WeakRef clears depends on what ran before it; the test failed when cache.test.ts ran beside bandwidth.test.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Responses to the review-body findings at b5f1e11, now on b351e24:
Details are in the body's (Written by Claude Opus 5.5) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed b351e24 incrementally from b5f1e11.
Both prior P2 findings are addressed. Finished-group shedding now visits the track-held mirrors, clears unread queues and reports Evicted to readers that lost frames, while a drained reader keeps its clean end. The regression covers both readers. The eviction order now advances a head index with amortized compaction instead of shifting the array; removing a group empties its shared slot, including references before the head, so the queue does not retain evicted payloads.
No new actionable findings. Keeping the newest committed frame behind an unfinished frame also aligns shedding with the intended readable-tail rule. The pool/account/charge direction remains sound. Removing a GC-order-dependent assertion is reasonable; the retention correction is visible in the ownership path, though I did not verify collection experimentally.
Verification limits: GitHub-only static review of the substantive follow-up commits, complete cache implementation and surrounding group/track mirror and eviction paths. No tests, memory probes, browser/V8 benchmarks or reported failing-suite reproduction run. Default pools remain unbounded; these byte-pressure fixes concern explicitly configured internal capacities. Rechecked open/non-draft state, full head and published reviews immediately before this COMMENT.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run covered the three commits since the prior pullfrog review (b5f1e11..b351e24), which address the two OpenAI P2s and both prior pullfrog threads.
- Shed keeps the newest committed frame (
group.ts):#shednow computeskeep = closed === null ? 0 : writing ? 2 : 1, so an open group never sheds the frame a reader can still take while a newer one is being filled, matching Rust'slast = committed - 1. - Shedding a finished group ends its lagging copies (
group.ts,track.ts,internal.ts):#shedtakes the track'sentry.mirrorsasreadersand, for a finished group, fails each copy whose#state.startis behind the shed front withEvicted. A copy that already took every shed frame keeps reading to its clean end, which the new test asserts. - Eviction order is a head-indexed ring (
cache.ts):#livemaps each entry to aSlot,removeempties the slot, and#payDebtadvances a#headindex instead ofshift()/unshift(). I traced the ring invariants: every examined live slot is re-pushed or restored through#head--, an emptied account resets the order and the loop breaks, and#compactonly slices off pre-head dead duplicates, so no live entry can be dropped and no evicted group is pinned. - Regression tests:
a debt shed keeps the newest committed frame behind one being writtenanda debt shed of a finished group ends the copies that lost a frame, both exercising the new paths; the order-dependentWeakRefretention test was removed with its cause documented. - Hook signatures:
shedGroupgainedreadersandevictGrouptakes the lossError, withtrack.tssupplying both.
bun test in js/net passes here (3141 pass, 0 fail; the body's order-dependent bandwidth.test.ts failure did not reproduce in this checkout) and tsc --noEmit is clean. The finished-group shed keeps the pool accounting symmetric: the source Charge is credited on #shift, and mirrors bill only MIRROR_OVERHEAD, so clearing a behind mirror's queue does not double credit.
The prior thread declining a refund for mirror overhead on a closed track stands: entry.mirrors genuinely retains those mirrors and #unlink refunds them when the entry leaves. The missing capacity setter remains the body's recorded decision and is owned by the drop-set follow-up, so I am not re-raising it.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

Problem
js/netbounded its cache only by time: each track's#pruneaged groups out on a 30 s idle window, andgroup.tscapped one group's frame tail at 32 MiB. Nothing billed bytes across a broadcast's groups or tracks, so JS could not bound a broadcast in bytes the way Rust'scache::Pooldoes, and the Drop set quest had no pool to bill its runs to.Closes the quest
quest/m1/js-cache-pool.md.Approach
js/net/src/cache.tsports Rust'srs/moq-net/src/model/cache.rs, keeping its names and shape:Pool: one per broadcast (a track with no broadcast gets its own). It holds a byte capacity, the idle expiry (DEFAULT_EXPIRY, 30 s), the access-stamp mean, and the sweep timer. It defaults asbroadcast::Info::defaultdoes in Rust: no capacity and 30 s expiry.Track: a track's account. While the pool is over capacity, writes accrue eviction debt (written * used / capacity, doubled when the oldest group is stale, capped atused). The account pays it by evicting its own oldest groups withEvicted. A group read after the pool's mean rotates to the back instead, and a group bigger than the remaining debt carries the debt over. The live edge is protected; with nothing evictable, the debt sheds the fronts of fresh groups and of the live edge.Charge: a group's bill. It covers frame bytes plus a fixed per-group overhead, and stamps access on write (boost 1) and read (boost 2) in 100 ms ticks.#prune's per-track timer. The sweep holds only tracks with an evictable group and never holds a process open.Wiring:
track.tsbills each cached group through its account, evicts through the pool, and charges a fixed overhead per subscriber mirror.group.tsbills frame writes and tail trims, sheds its front on demand, and refreshes access on frame reads. A subscriber taking a group refreshes it too, as Rust'stakedoes.broadcast.tsgives every track of a broadcast the same pool.Producer.#cacheis now an insertion-orderedSet, so evicting a run of groups is linear.Impact
cache.tsand the newhooksentries are package-internal.CACHE_EXPIRYleft the internalledger.tsforcache.DEFAULT_EXPIRY.js/net/api/index.api.mdis unchanged.Choices made without asking
Each one is open to review; my recommendation is to keep it.
TooFarBehind; byte eviction usesEvicted. Rust ends idle groups differently. Keeping JS's existing error stays inside this quest's scope.now()is cheap in JS.Alternatives
#pruneand add a byte cap per track: rejected by the quest's 2026-11-03 decision, which picked a shared per-broadcast pool so tracks of one broadcast compete for one budget, as in Rust.cache.ts.Validation
just cion b351e24: everything passes except one@moq/nettest,a connection samples the transport only while a reservation reads the estimate(js/net/src/connection/bandwidth.test.ts), with 3140 pass and 1 fail. That failure is not from this PR. It depends on file order: Bun runs the files in directory-read order, and in this checkoutbandwidth.test.tsruns afterstats.test.ts.origin/main'sjs/netfails the same way in that order, and this PR's head passes the full suite in a fresh worktree, where the order differs. No open PR fixes it yet.a debt shed keeps the newest committed frame behind one being writtenanda debt shed of a finished group ends the copies that lost a frame.Array.shiftcost (.tmp/probe, not committed): 100k rotate-and-restore steps took about 10 ms in Bun at every size from 1k to 1M entries. In Node (V8) they took 22 ms at 1k, 95 ms at 10k, and 20.3 s at 100k. The account's eviction order now uses a head index instead.just relay-fillpassed,bun net/bench/track.tspassed its slope guard, mutation checks oncache.test.tsheld, and the idle-expiry scaling probe showed a linear sweep (2k, 20k, and 100k groups in 4.7, 39.3, and 169.4 ms).Follow-ups
js/nettests onto a mocked clock and touchestrack.test.ts,parked-expiry.test.ts, and others edited here. It does not conflict withmaintoday. Whichever lands second merges the other.Review feedback addressed
group.ts, shedding the committed frame behind one still being written: fixed in e93ce5c. It now keeps the newest committed frame, as Rust'sGroupState::sheddoes.track.ts, mirror overhead on a closed track: declined.entry.mirrorsreally does retain those mirrors, so the bill is accurate, and#unlinkrefunds it when the entry is evicted or expires.Evicted. Whole-group eviction already ended them this way.#order.shift()/unshift(): fixed in e93ce5c and 73ab959. The order reads from a head index, and each group holds a slot that removal empties, so the order never pins an evicted group's frames. I did not add a capacity-limited row tobench/track.ts, because that bench runs on Bun, whereshiftis already O(1). It could not catch this cost, which shows up only in V8.Models
Review
quest review --base origin/main.origin/mainmoved): no findings.js/net/src/cache.ts:411[high] "Release evicted entries instead of retaining their payloads": with the head index from e93ce5c, slots before#headstill referenced evicted groups. Each group now holds a slot thatremoveempties. AWeakReftest for this was dropped in b351e24, because JSC's conservative GC made it depend on test order.(Written by Claude Opus 5.5)
🤖 Generated with Claude Code