Skip to content

fix(moq-net): deliver groups from a takeover source that restarted its numbering - #2534

Open
wrangelvid wants to merge 4 commits into
moq-dev:mainfrom
wrangelvid:fix/linger-restarted-numbering
Open

fix(moq-net): deliver groups from a takeover source that restarted its numbering#2534
wrangelvid wants to merge 4 commits into
moq-dev:mainfrom
wrangelvid:fix/linger-restarted-numbering

Conversation

@wrangelvid

@wrangelvid wrangelvid commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Root cause: the linger takeover (feat(moq-net): linger a broadcast across an ungraceful source loss #2469) splices a replacement source at latest + 1, assuming it resumes the old sequence space. A reconnecting publisher is a fresh session with a fresh identity and restarted numbering, so the boundary starves it twice: the demand floor asks it for groups it will never number, and late-join floors resolved against the pre-restart latest() filter anything that does arrive. One-group-per-event feeds lose the next N events after every publisher reconnect.
  • The takeover now gates on the announce's first hop, the producer-instance identity. A matching first hop splices at the boundary exactly as before (eager demand floor, unchanged route behavior). A differing first hop is a new broadcast: the logical track resets onto it unbounded and a numbering era bumps, clearing subscriber cursors and late-join floors anchored in the old numbering.
  • A same-identity source producing below the splice boundary logs a one-shot warning (an identity-contract violation that would starve those groups); the Origin doc now states that ids are minted per producer instance.
  • The route tests' synthetic hops are adjusted so alternate routes to the same producer share a first hop; the restart tests model a reconnect as a fresh session with a fresh identity.

Public API changes

None. New items are pub(crate) (resume::Producer::reset_onto, resume::Producer::splice_boundary, track::Consumer::poll_latest_changed). One doc addition on Origin.

Test plan

  • test_linger_reconnect_restarted_sequences_still_deliver fails on main, passes here.
  • test_linger_restart_resets_late_join_floors fails without the era cursor reset, passes here.
  • test_linger_reconnect_splices and the route handover/failover/cost tests pass with their original assertions; 559 moq-net tests green.
  • An out-of-tree integration suite driving the relay binary reproduces the starvation on stock 0.14.2/0.14.3 (an event-feed test failing ~90% of runs after a publisher reconnect, a publisher-rotation test starving 3/5); both pass consistently with this branch's relay.

(Written by Claude Fable 5)

@wrangelvid
wrangelvid marked this pull request as draft July 27, 2026 19:54

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • In Consumer::poll_latest_changed, the Spliced case silently returns Poll::Pending; consider either documenting this explicitly at the call site or making it an invariant (e.g., via a debug assert) that this helper is only used with Plain consumers to avoid future misuse.
  • The new era field and restart logic in resume::Producer::reanchor_restarted introduce a second timeline alongside epoch; consider grouping or encapsulating the epoch/era handling (e.g., via a small helper or struct) so callers and future changes don’t have to remember the subtle interaction between the two counters.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `Consumer::poll_latest_changed`, the `Spliced` case silently returns `Poll::Pending`; consider either documenting this explicitly at the call site or making it an invariant (e.g., via a debug assert) that this helper is only used with `Plain` consumers to avoid future misuse.
- The new `era` field and restart logic in `resume::Producer::reanchor_restarted` introduce a second timeline alongside `epoch`; consider grouping or encapsulating the epoch/era handling (e.g., via a small helper or struct) so callers and future changes don’t have to remember the subtle interaction between the two counters.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

wrangelvid and others added 3 commits July 27, 2026 12:57
…bering

A broadcast in its linger window accepts a replacement source via
takeover(), which splices it in at group_start = latest + 1 on the
assumption that the replacement resumes the previous sequence space.
A real reconnecting publisher (a fresh session with a fresh
broadcast::Producer) restarts numbering at zero, so every group it
publishes falls below the segment boundary and is filtered on the
read side: subscribers stall silently until the restarted source's
sequence happens to catch up, swallowing as many groups as the old
source ever delivered.

For a low-rate event feed (one group per event) this is a lasting
outage: a relay consuming an event publisher that restarts
loses the next N events after the reconnect, where N is everything
delivered before it. The existing test_linger_reconnect_splices
constructs its continuation group at the resume boundary explicitly,
so this path was uncovered.

The new test fails with 'group would have blocked' on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A takeover splices the replacement source at one past the newest
delivered group, assuming it resumes the old sequence space. A real
reconnecting publisher (fresh session, fresh producer) restarts
numbering at zero, so every group it publishes sits below the
boundary: the demand floor keeps the wire subscription from ever
requesting its groups, and the read cursor filters anything that
arrives, starving subscribers silently for as many groups as the old
source ever delivered.

Takeover splices are now PROVISIONAL: the boundary binds the read
cursor but stays out of wire demand until the source's first
production shows which case this is. Producing at or past the
boundary proves a resume and promotes the floor into demand
(subscribers re-slice on the epoch bump); producing sequence zero
below a boundary of two or more proves a restart, and the splice
resets onto the new source alone so subscribers reconcile onto its
own numbering. In between (a resumed source re-delivering history
into an unfloored subscription) stays provisional and filtered.
Boundary one is the ambiguous corner, resolved as a resume: a
restarted source recovers at its next group, which lands on the
boundary.

The origin dispatcher watches a freshly spliced copy's production
edge (a new demand-free track pollable) and resolves the splice; the
route tests pin the deferred-floor contract and the restart test
delivers a restarted source's first group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A restart reanchor re-splices the logical track onto a source with
its own numbering, but subscribers created between the takeover and
the deciding production carry floors resolved against the OLD
numbering (a session's SUBSCRIBE start resolves from the pre-restart
latest). Those floors filter everything the restarted source will
number for as many groups as the old source ever delivered.

The resume state now carries a numbering era, bumped only by a
restart reanchor; a subscriber observing an era change resets its
monotonic position and start floor before reconciling segments, so
the restarted source's groups surface under their own numbering.
The late-join-floor test fails without the reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wrangelvid
wrangelvid force-pushed the fix/linger-restarted-numbering branch 2 times, most recently from d7023cc to 356e97b Compare July 27, 2026 20:04
@wrangelvid
wrangelvid marked this pull request as ready for review July 27, 2026 20:05

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @wrangelvid, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Introduces production-aware splice settlement and numbering-era tracking. Serving now distinguishes resumed and restarted replacement tracks using route origin identity and first-production observation. Restarted numbering increments a resume era and resets subscriber sequence cursors and floors. Route and linger reconnect tests cover the updated splice and delivery behavior.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: handling takeover sources that restart numbering.
Description check ✅ Passed The description is directly related and explains the same numbering-restart takeover fix and tests.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kixelated

Copy link
Copy Markdown
Collaborator

I'll check in a second, but we shouldn't be trying to resume broadcasts with different origin IDs. Like the relay needs soooome indication that a broadcast is new versus continuous, and right now we should be using the first hop as that flag.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rs/moq-net/src/model/resume.rs (1)

396-412: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Seed era from the current state, not 0.

Subscriber::era starts at 0 while ResumeState::era may already be nonzero (any earlier restart reanchor). The first poll_sync then sees an era mismatch that did not happen during this subscriber's lifetime and clears min_sequence/next_sequence, silently discarding a start_at() floor set before the first poll. That is the exact late-join pattern the new test drives, so a subscriber attaching after a restart has already settled loses its floor once.

🐛 Proposed fix
 	pub(crate) fn subscribe_shared(&self, prefs: kio::Producer<Subscription>) -> Subscriber {
 		let last_prefs = prefs.read().clone();
+		let era = self.state.read().era;
 		Subscriber {
 			state: self.state.clone(),
 			prefs,
 			last_prefs,
 			epoch: 0,
-			era: 0,
+			era,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/model/resume.rs` around lines 396 - 412, Initialize
Subscriber::era in subscribe_shared from the current ResumeState era rather than
zero. Preserve the existing state and preference initialization, and ensure a
late-joining subscriber retains any start_at() floor when the state has already
undergone a restart reanchor.
🧹 Nitpick comments (2)
rs/moq-net/src/model/origin.rs (1)

3740-3797: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a restarted-source case where the first observed latest is above zero.

Every restart test appends exactly one group before settling, so reanchor_restarted always sees latest == 0, which is the only value its current heuristic treats as a restart. Appending two or three groups back-to-back before settle().await (or producing before the splice) exercises the coalesced-observation path that the heuristic misses; see the related comment on rs/moq-net/src/model/resume.rs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/model/origin.rs` around lines 3740 - 3797, The test
test_linger_reconnect_restarted_sequences_still_deliver currently only observes
latest sequence 0 after reconnect. Extend the restarted-source scenario to
append at least two groups before settling or splicing, then assert the
restarted groups are delivered with the expected sequences, exercising
reanchor_restarted when the first observed latest value is above zero.
rs/moq-net/src/model/track.rs (1)

1568-1578: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

The closed arm ignores known, so it stays ready forever.

Once the plain track closes, state.poll yields Err(final_state) on every poll and this returns Ready(closed.max_sequence) even when it equals known. Today the caller is saved only by branch ordering: poll_complete is checked before this in serve_track's wait closure and is unconditionally ready on close, so the loop exits via Step::Failed/Step::Complete. If those branches are ever reordered, an unsettled splice would busy-spin. Cheap to make self-consistent:

♻️ Proposed change
-			Poll::Ready(Err(closed)) => Poll::Ready(closed.max_sequence),
+			// A closed track produces nothing further, so only report an actual change.
+			Poll::Ready(Err(closed)) if closed.max_sequence != known => Poll::Ready(closed.max_sequence),
+			Poll::Ready(Err(_)) => Poll::Pending,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/model/track.rs` around lines 1568 - 1578, Update the
closed-state arm of the state.poll match in the track wait logic to compare
closed.max_sequence with known and return Poll::Pending when they are equal;
only return Poll::Ready when the final sequence advances, preserving the
existing behavior for successful polls and pending states.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rs/moq-net/src/model/resume.rs`:
- Around line 266-280: Update the restart-detection condition in the reanchoring
logic after latest() so any latest value below start is treated as a restarted
source, rather than requiring latest != 0. Retain only the existing start < 2
boundary-of-one ambiguity guard, and preserve the Pending outcome for that
ambiguous case.

---

Outside diff comments:
In `@rs/moq-net/src/model/resume.rs`:
- Around line 396-412: Initialize Subscriber::era in subscribe_shared from the
current ResumeState era rather than zero. Preserve the existing state and
preference initialization, and ensure a late-joining subscriber retains any
start_at() floor when the state has already undergone a restart reanchor.

---

Nitpick comments:
In `@rs/moq-net/src/model/origin.rs`:
- Around line 3740-3797: The test
test_linger_reconnect_restarted_sequences_still_deliver currently only observes
latest sequence 0 after reconnect. Extend the restarted-source scenario to
append at least two groups before settling or splicing, then assert the
restarted groups are delivered with the expected sequences, exercising
reanchor_restarted when the first observed latest value is above zero.

In `@rs/moq-net/src/model/track.rs`:
- Around line 1568-1578: Update the closed-state arm of the state.poll match in
the track wait logic to compare closed.max_sequence with known and return
Poll::Pending when they are equal; only return Poll::Ready when the final
sequence advances, preserving the existing behavior for successful polls and
pending states.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 199af34e-500e-4913-a2c6-0f02c9506eb0

📥 Commits

Reviewing files that changed from the base of the PR and between 1c2584f and 356e97b.

📒 Files selected for processing (3)
  • rs/moq-net/src/model/origin.rs
  • rs/moq-net/src/model/resume.rs
  • rs/moq-net/src/model/track.rs

Comment thread rs/moq-net/src/model/resume.rs Outdated
Per review: the first hop is the producer-instance identity, so the
takeover no longer infers resumed-vs-restarted from what the source
produces. A route whose first hop matches the previously serving one
splices at the boundary exactly as before (the demand floor stays
eager, and the route tests keep their original assertions, with their
synthetic hops adjusted so alternate routes to the same producer share
a first hop). A differing first hop is a new broadcast: the logical
track resets onto it unbounded and the numbering era bumps, which
clears subscriber cursors and late-join floors anchored in the old
numbering.

This replaces the provisional-splice and production-heuristic
machinery from the previous revision; what remains of it is a one-shot
warn when a same-identity source produces below the splice boundary
(an identity-contract violation that would starve those groups), plus
the Origin doc spelling out that ids are minted per producer instance.
The restart tests model a reconnect as a fresh session with a fresh
identity, which is what real ones are.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wrangelvid

Copy link
Copy Markdown
Contributor Author

I'll check in a second, but we shouldn't be trying to resume broadcasts with different origin IDs. Like the relay needs soooome indication that a broadcast is new versus continuous, and right now we should be using the first hop as that flag.

Oh I didn't see that. I reworked this by keeping the boundary splice on the same first hop, and a different one resets the logical track onto the new source with an era bump, which should also clear later join floors resolved against the old numbering.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
rs/moq-net/src/model/resume.rs (2)

614-620: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Drop an in-flight old-era group before returning frames.

poll_read_frame checks self.reading before it synchronizes producer state. If reset_onto occurs after an old group enters reading, the next call can return a frame from the replaced broadcast. Synchronize first and clear reading on an era change.

Proposed fix
 if era != self.era {
 	self.era = era;
 	self.next_sequence = 0;
 	self.min_sequence = 0;
+	self.reading = None;
 }

 pub fn poll_read_frame(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<frame::Frame>>> {
+	self.poll_sync(waiter);
 	loop {

As per coding guidelines, add a regression test that selects an old group, resets the producer, then verifies the next frame comes from the new source.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/model/resume.rs` around lines 614 - 620, Update
poll_read_frame to synchronize producer state before checking or returning from
self.reading, so an era change detected by reset_onto clears any in-flight
old-era group before frames are emitted. Add a regression test covering
selection of an old group, producer reset, and verification that the next frame
comes from the new source.

Source: Coding guidelines


356-356: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Seed new subscribers with the current era.

A reset while no subscriber exists leaves state.era > 0. This subscriber then treats its first snapshot as a restart and clears a caller-set start_at floor before its first poll. Initialize from self.state.read().era so only resets after subscription creation clear its cursors.

Proposed fix
 pub(crate) fn subscribe_shared(&self, prefs: kio::Producer<Subscription>) -> Subscriber {
 	let last_prefs = prefs.read().clone();
+	let era = self.state.read().era;
 	Subscriber {
 		state: self.state.clone(),
 		prefs,
 		last_prefs,
 		epoch: 0,
-		era: 0,
+		era,

As per coding guidelines, add a regression test that fails without this fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/model/resume.rs` at line 356, Initialize the new subscriber’s
era from self.state.read().era instead of the hardcoded 0 in the subscriber
construction path, preserving caller-provided start_at when prior resets
occurred before subscription. Add a regression test covering a reset with no
subscribers followed by subscription and verify the first poll does not clear
its start_at floor.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rs/moq-net/src/model/resume.rs`:
- Around line 614-620: Update poll_read_frame to synchronize producer state
before checking or returning from self.reading, so an era change detected by
reset_onto clears any in-flight old-era group before frames are emitted. Add a
regression test covering selection of an old group, producer reset, and
verification that the next frame comes from the new source.
- Line 356: Initialize the new subscriber’s era from self.state.read().era
instead of the hardcoded 0 in the subscriber construction path, preserving
caller-provided start_at when prior resets occurred before subscription. Add a
regression test covering a reset with no subscribers followed by subscription
and verify the first poll does not clear its start_at floor.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 24bcfd3c-ca2d-4ff5-9fdd-2312f8b80fa4

📥 Commits

Reviewing files that changed from the base of the PR and between 356e97b and 4d33fb8.

📒 Files selected for processing (3)
  • rs/moq-net/src/model/origin.rs
  • rs/moq-net/src/model/resume.rs
  • rs/moq-net/src/model/track.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • rs/moq-net/src/model/track.rs
  • rs/moq-net/src/model/origin.rs

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.

2 participants