Repository navigation
feat(net): flights: a reader at a shed frame replays through a separate paced copy - #7944
Conversation
Quest-Claim: bfa350d08240eced
…copy and hands over Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opy ahead's burst Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…answers stay within one tail Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the quest's remaining work Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cloning Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… flight, FETCH answers count each object Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ms after FETCH_OK Applies the maintainer's 2026-11-05 decisions on the flights PR: - A compressed group's flight starts at frame 0 and its riders skip to their frame, inflating the prefix with the flight's own decoder. A plain reader also boards once its inflated copy shed the frame it reads next. - A moq-transport FETCH learns its group's edge and answers FETCH_OK from metadata, then streams the objects, riding flights, with no whole-group buffer. A FETCH of the track's newest live group is refused unless it ends at or below the group's edge; an older group still arriving is waited on. - Paced batch fills move to a quest of their own; flights.md is finished. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…once its group turns unfetchable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Design change: applying the maintainer's 2026-11-05 decisions. Compressed groups now fly, from frame 0, with riders skipping to their frame. The moq-transport FETCH path no longer reads the answer into memory before FETCH_OK: it learns the group's edge, answers from metadata, refuses a FETCH of the newest live group that reaches past its edge, and streams the objects after FETCH_OK through the fill path's (Written by Claude Opus 5.5) |
…ing rider keeps its skip A FETCH waiting for a cold group to finish arriving parks a cursor past every frame, so the copy's paced ingest runs on instead of waiting on a reader that takes nothing. A compressed rider that boards again while skipping to its frame carries the frames it still has to skip. 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 16 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 commit 729e30d. Detached paced copies and streaming after metadata-derived FETCH_OK are a reasonable direction without restoring whole-group buffering.
- [P2] Cancel the old flight when recovering onto another route (
rs/moq-net/src/model/group.rs:4065–4075).poll_resumedadopts the replacement cursor but leavescarry.ride.boardingfrom the old route. This block polls that old request first: an unanswered flight keeps the reader pending despite available replacement data, a refusal yields spuriousEvicted, and an old success can overwrite the replacement cursor. Cancel old boarding on adoption, preserving any compressed-prefix skip separately. Test an unanswered flight on A followed by a healthy route B. - [P2] Propagate expiry failures while settling a FETCH (
rs/moq-net/src/ietf/publisher.rs:220–224). The parked cursor's result is discarded whilepoll_settlewaits on raw group closure. An older unfinished group that exceeds the reader's max-age budget returnsOldfrom the cursor but can leave FETCH pending until unrelated FIN, eviction or cancellation. Propagate terminal cursor errors or explicitly apply expiry during settlement; test an older unfinished group aging out without FIN.
Verification: GitHub-only static review of recovery, pacing, expiry and test paths. I did not run tests.
There was a problem hiding this comment.
Re-raising the two findings from the prior automated review on this head, both verified against the current source. The flights/pacing design itself reads as sound: the admission window's arithmetic (ADMIT = pace::AHEAD - SEGMENT_HEAP, with held() as the log base less the adopted front) leaves the reader's frame in place when the writer runs a whole window ahead, and admits correctly rejects adopted front frames and uncompressed readers of a compressed copy whose inflated copy shed the frame. The issues below are in the state transitions around that design, not in its premise.
1. Route recovery leaves the reader's flight state behind
poll_resumed (rs/moq-net/src/model/group.rs:3742-3752) replaces self.cursor with the replacement copy's cursor but does not touch self.carry. The Carry.ride (:3409, Ride.boarding at :3432) therefore survives the adoption, and the next poll_flown (:4059-4076) polls the old route's boarding first:
- an unanswered boarding returns
Pending, parking the reader despite the replacement copy having its frame (:4075); - a refusal clears the ride and returns
Evicted(:4069-4071), even though the replacement route is healthy; - a success calls
adopt_copy(copy)and resetscursor.index = start(:4077-4082), moving the reader back onto the old route's flight and overwriting the replacement cursor's index and copy.
Concrete case: a reader boarding a flight on route A while a newer route B serves its frame. B's copy is adopted, then the stale A boarding wins and either parks or reverts the reader. Clear the ride when a replacement copy is adopted in poll_resumed, so no boarding or flight outlives the route it belongs to; keep the compressed-prefix skip only when the replacement read still needs it. A test with an unanswered flight on A followed by a healthy B would cover this.
2. A settling FETCH discards the parked cursor's expiry
rs/moq-net/src/ietf/publisher.rs:220-224:
let mut past: group::Consumer = found.1.clone().with_range(u64::MAX..);
let end = wait!(Settle => {
let _ = past.poll_next_frame(waiter);
poll_settle(waiter, track, range, &found.1, self.joined)
})?;The parked cursor's result is dropped. A fetch_group consumer does carry the caller's max_delay as a FetchExpiry (rs/moq-net/src/model/track.rs:5761-5774), so past.poll_next_frame returns Err(Error::Old) once an older unfinished group falls past that budget. That error never reaches poll_settle, whose group.poll_closed only resolves on finish or abort (rs/moq-net/src/model/group.rs:2304-2306). The FETCH then stays pending until unrelated FIN, eviction, or cancellation — a hang where the previous read-before-FETCH_OK path refused the request. The removed a_fetch_past_max_age_is_refused_not_truncated covered exactly this; its replacement should fail the settle on a terminal cursor error (or apply the expiry during settlement), with a test for an older unfinished group aging out without FIN.
Verdict: request changes for the two above. The rest of the diff — the flight fill's covers, Fill::wanted bypassing the cache for flights, pool debt shedding flights after the fresh slots, the compressed flight starting at frame 0 with the rider's Ride::skip, and the moq-transport metadata-derived FETCH_OK — matches the settled decisions and the documented tests.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

Finishes quest/m1/unbounded-groups/flights.md and deletes it; its remaining batch-fill work moves to the new Paced batch fills quest under the same epic.
Problem
A reader that reaches a frame its group copy shed gets
Error::Evicted. That covers a late joiner wanting frame 0 of a long group, or a reader that fell more than the 32 MB tail behind. On a track that opts into refresh it asks for a new group; otherwise it has nothing to go on. Without flights, a long-lived group can't be replayed even when the publisher can serve old frames.What changed
A flight is a separate copy of a group from the frame a reader needs. It is asked of the track's
track::Requestshandler, held to its own 32 MB tail, and paced by its fastest rider through the existing fastest-reader pacing.Boarding (
model/group.rs,poll_flown). A frame read that fails because the copy shed the frame read next boards a flight, provided the track has a handler. The reader rides an existing copy that admits it, or asks a new one from its frame. It then reads on from that copy at the same index, the wayRecoveradopts a replacement. Boarding happens belowRecover, at the copy the cursor reads, so a relay's route copy asks its own upstream.Admission (
group::Handle::admits,group::ADMIT). A copy admits a reader whose frame it holds no further thanADMITbehind the end of the last frame its fastest reader took.ADMITis 16 MiB (pace::AHEAD) less one full log segment (the newkio::log::Writer::SEGMENT_HEAP). Bytes are measured on the copy's own pace scale.Handover (
track::hand_over,TrackState::ride). A rider checks, whenever its prefetched batch is drained, for a copy further along (the cached copy or another flight) that admits its frame. If one does, it switches there at the same frame. Its flight is then unread, so the lite FETCH handler cancels it upstream, and every hop above follows.Flight fills (
model/track/fill.rs,FillState::fly). A flight is a fill of one group from a frame that never joins by sequence (covers). Readers asking from the same frame before it is answered share it. Its accepted copy is detached (TrackState::insert_flight): never served in place of the cached copy, registered weakly inTrackState::flightsfor admission, and shed under pool debt with the slot it replays.Fill::wantednever answers a flight from the cache, since the cached copy is the one that shed the frame.Compressed groups (decision 1). A compressed group's flight starts at frame 0, since every later frame builds on the ones before it, and its rider reads and drops the frames before its own (
Ride::skip), so pacing and the flight's own decoder see them in order. A compressed flight keeps its own content rather than sharing the cached copy's, whose inflated copy is further on. A plain reader of a compressed copy also boards once the shared inflated copy shed its frame, which the decoder, already past it, cannot inflate again. Admission additionally requires that a plain reader can still inflate its frame from the copy (every slice since the last inflated frame held, from frame 0).Falling off. A rider that falls off its flight's tail boards again by the same rules. Boarding happens at most once per read, so a copy shed before the reader takes a frame (pool debt outrunning it) fails as
Evictedrather than looping.Fallback. With no handler, or a refused flight, the read stays
Evictedand a reader of the newest group asks for a new group on a refresh track, as before.Budgets and memory. A read waiting on its flight is judged by the reader's drift budget like one parked at the edge. Pool debt sheds every flight after the fresh slots, whether or not its group's cached copy survives.
moq-transport FETCH streams after FETCH_OK (decision 4,
ietf/publisher.rs).PrepareFetchno longer reads the answer into memory (ReadFetchandFetchedGroupare gone). It finds the group (FindFetch), then learns its edge (poll_settle):NOT_SUPPORTED("FETCH of a live group must end below its live edge") before reading anything;FETCH_OK's End Location,
end_of_track, and properties come from that metadata, then the objects stream through the existingFetchGroupWrite(the fill path's writer), riding flights where the copy shed them, with no whole-group buffer and noGroupTooLargecap. A failure after FETCH_OK, such as an object whose properties the draft cannot carry, resets the stream. FETCH stays single-group. Thejs/netIETF publisher already streams without copying the group (hooks.groupEndwaits on headers through the reader's normal tail), so it is unchanged.Docs: the relay cache section (
doc/bin/relay/config.md), the group module docs andMAX_CACHE_BYTES,track::Requests, and a handler note inrs/moq-net/MIGRATE.md.Quests:
flights.mdis deleted (finished), and Paced batch fills takes its place in the epic'sRequired. The epic README records the four decisions below. Byte holes, Resume redesign, and moq-transport fill refill needed only the mechanism landed here, so their links to Flights point at this PR.Tests
Model (
model/track.rs), each against a 48 MiB live group, half again the tail:a_late_joiner_rides_a_flight_and_hands_over: the joiner boards a flight from frame 0, reads it as the handler writes it, and hands over to the live copy between frames 1900 and 2000, as the 16 MiB bound predicts. The flight is then unwanted. Right after the handover the live writer fills its whole ahead window at once, and the rider still keeps its frame.a_compressed_flight_starts_at_frame_0_and_its_rider_skips_ahead(new): a plain reader of a compressed group that fell off at frame 10 boards a flight asked from frame 0, gets nothing while the handler writes frames 0 to 9, reads on from frame 10, and hands over to the live copy.joiners_within_reach_share_one_flight: two joiners at frame 0 share one fill. A third joining 8 MiB behind the rider boards the same flight; one joining 17 MiB behind asks its own.a_slow_rider_falls_off_and_flies_on_its_own: the flight's writer is never held back by a rider that stops reading, and that rider boards a new flight from its frame once it falls off.pool_debt_sheds_a_flight: under a 4 MiB pool, a live group and its flight stay under twice the pool.a_refused_flight_falls_back_to_refresh: a refusal leavesEvicted, and a refresh track asks for a new group.a_reader_boarding_a_flight_goes_stale: a reader whose flight the handler never answers still getsOldonce a newer group supersedes it.pool_debt_sheds_a_flight_without_its_cached_group: debt sheds a flight whose group's cached copy was evicted whole.moq-transport publisher (
ietf/publisher.rs, every FETCH draft):a_standalone_fetch_of_the_live_group_ends_below_its_edge(replacesa_standalone_fetch_waits_for_the_largest_object_group): a whole-group FETCH of the live group and one ending past its edge are refusedNOT_SUPPORTED; one ending at its edge is answered at once, withoutend_of_track.a_standalone_fetch_waits_for_an_older_group_to_finish(new): an unfinished group older than the newest is waited on, then answered.a_fetch_past_max_age_is_refused_not_truncatedis removed: nothing is read before FETCH_OK any more, so there is no prefix to truncate, and a failure after it resets the stream.Integration (
tests/integration/flights.rs), publisher, two relays, and a viewer, with the publisher's handler serving fills from an archive:a_late_joiner_replays_through_a_relay_chain(lite-05, lite-06, lite-07-wip): a late joiner at the bottom replays all 3000 frames from frame 0. Each hop serves the flight from its own flight, and lite-05 widens to frame 0. The joiner hands over, and the origin's single flight stops. No hop's pool held more than 80 MiB.moq_transport_refuses_a_replay_of_a_live_group: over moq-transport-22 the upper relay refuses the lower one's whole-group FETCH of the live group before reading it, so the joiner gets an error and no flight reaches the origin.moq_transport_relays_a_cold_finished_group(new): a relay holding none of a finished 48 MiB group answers a viewer's moq-transport FETCH once its own copy finishes arriving, then streams it. Times out without the parked cursor described under Choices.moq_transport_replays_a_finished_group(new): the same chain with the group finished. Each moq-transport hop answers FETCH_OK from metadata and streams the group through its own flight from frame 0, the joiner reads all 3000 frames and the group's end, and the origin's one flight ends.session_idle::a_fetch_keeps_both_ends_busynow covers lite only: moq-transport refuses its whole-group FETCH of the live group.Measurements
group_flights(rs/moq-net/benches/group.rs): a 48 MiB live group replayed from frame 0 to late readers, each joining once the one before it readapart. Swept over readers and spacing; this host (desktop, 24 cores),quest gate, criterion medians:The ordinary read path does not regress.
just bench-fanout origin/main, which counts instructions per delivered group independent of host load, gave these results against the merge base 9227f82bc1:Every cell is within the ~0.5% that hash seeds alone move a count (
bench/fanout.py).These figures are from head
20b75b38de, before this round. Since then the plain read path gained one branch per read (the skip count, checked only while a reader carries a ride) and one per shed check, both off the common path.Decisions
Settled by the maintainer on 2026-11-05 (/quest-plan), recorded in the epic README's Plan:
EvictedEvictedChoices made without asking
kio::log::Writer::<Entry>::SEGMENT_HEAP, the heap of the largest (64-slot) segment.Recover, so each relay hop asks its own upstream and the recursion falls out of the existing FETCH paths.Evicted. A later read may board again.Fill::is_wantedalready says that once a group is accepted and taken,group::Producer::demandsays who reads it, so a handler stops a flight on both. The lite FETCH handler already does this.Recoveralready does, on a group's stored slices being the same in every copy.track::Consumer::is_live). An unfinished older group, or any group on a fetch-only copy, is treated as still arriving and waited on until it finishes or its edge passes the range, which is the buffering needed to learn the edge; the cache holds it, the FETCH holds nothing. A refused live FETCH answersNOT_SUPPORTED. The moq-transport subscriber still asks a flight's group whole, so moq-transport hops replay finished groups only, as the maintainer's rule implies.with_range(u64::MAX..)), so a cold group's paced ingest runs to its end instead of waiting on a reader that takes nothing. A group past one tail then sheds its front, and the stream after FETCH_OK rides a flight for it, so a cold relay fetches such a group's front twice. Resolving the answer from upstream metadata instead would need a per-draft path.Unsupportedonce its group turns unfetchable mid-way, which the old read checked per object before FETCH_OK.Consumer's cold state is merged into one box (Carry:Recoverand the ride) to stay within its 184-byte cap.Impact
kio::log::Writer::SEGMENT_HEAP(new const). Behavior: a track with atrack::Requestshandler now gets single-group fills from a frame for groups its cache holds live, from frame 0 on a compressed track (see MIGRATE.md).Checks
cargo nextest run -p moq-net --all-features --all-targets: 3479 passed, 13 skipped, on the final source (729e30dbec).cargo clippy -p moq-net -p kio --all-targets --all-features -- -D warnings: clean on the final source.cargo check -p moq-net --no-default-features: clean.quest check --base origin/main: 588 documents ok.just checkonda54613d88: passed, 11521 tests, 137 skipped. Since then onlyMIGRATE.mdwording and the two review fixes inmoq-netchanged, covered by the scoped runs above.Review
Codex (
quest review), gpt-6.1-sol, medium effort, a new session after the design change (see theDesign change:comment). Latest round: on head729e30dbec95057ef40f816659ecaeb20852d102, no findings.b6a62ffe46), two findings, both fixed:moq_transport_relays_a_cold_finished_group.729e30dbec): no findings.Earlier rounds, before the design change: round 1 on
d6765254ee(three findings, fixed) and round 2 on20b75b38de(none).Follow-ups
None.
(Written by Claude Opus 5.5)
🤖 Generated with Claude Code