feat(net): fail over across redundant publishers via per-peer route selection - #2473
Conversation
There was a problem hiding this comment.
Sorry @kixelated, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe change adds optional fixed origin IDs to the CLI and documents redundant publisher operation. Lite SETUP now carries endpoint origins, while ANNOUNCE_REQUEST Exclude Hop handling is limited to supported versions. Broadcast state retains complete ordered route tables with change polling. Origin routing filters excluded paths, preserves exclusions across consumers, and parks sources with different publisher identities. Lite publisher announcements select routes per peer, track serving paths, apply serving-only demand discounts, and recompute on route changes. Tests cover failover, split-horizon dispatch, route selection, and announcement retraction. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches✨ Simplify 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@doc/bin/cli.md`:
- Around line 75-76: Update the Redundant Publishers reference in the --origin
documentation to use the heading’s actual fragment, `#redundant-publishers-11`, so
the in-page link resolves and satisfies MD051.
In `@drafts/draft-lcurley-moq-lite.md`:
- Line 298: Replace the em dash in the advertisement constraint sentence with a
colon or other permitted punctuation, preserving the existing meaning and
wording.
🪄 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: e9b13394-e2cb-4291-bed4-03501ed712af
📒 Files selected for processing (7)
doc/bin/cli.mddrafts/draft-lcurley-moq-lite.mdrs/moq-cli/src/args.rsrs/moq-cli/src/main.rsrs/moq-net/src/lite/publisher.rsrs/moq-net/src/model/broadcast.rsrs/moq-net/src/model/origin.rs
TL;DRThanks for this — the per-peer selection design reads well and the model/wire unit tests
We don't think either is a reason to hold the PR (the mechanism it lands is a prerequisite Setup note: this PR predates #2469#2473 branched before #2469 ("linger a broadcast across an ungraceful source loss", Drill[cluster_failover.sh](https://github.com/user-attachments/files/30343689/cluster_failover.sh) Timeline: bring everything up; at t=10 Finding 1 — the standby route never reaches the relay serving the active sourceAcross every topology we tried (relay B dials A only; full mesh A↔B; and with/without a Our reading: the per-peer advertisement selection you added decides what to Is the intended design that the active-serving relay proactively holds all peer-advertised Finding 2 —
|
baeb69b to
0165ec9
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
rs/moq-net/src/model/origin.rs (1)
618-647: 🩺 Stability & Availability | 🔵 TrivialConfirm the shared-origin
Unroutableteardown is covered.Reviewers observed an
Unroutableteardown for a subscriber when a shared-origin standby joined a relay already carrying the broadcast from its peer (not seen with distinct origin IDs). That surfaces here: when the active chain contains the requester, resolution falls todispatch(Some(origin))and yieldsNone(→Unroutable) if every candidate route is tainted. The new tests cover the excluded-peer and all-tainted cases in the abstract, but not the shared-origin standby-join scenario the reviewers hit. Want me to draft a regression test reproducing that topology, or open an issue to track it?🤖 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 618 - 647, Add a regression test for consume_broadcast covering a shared-origin standby joining a relay already carrying the broadcast from its peer. Build the topology with the shared origin, relay, active peer route, and standby subscriber, then assert the standby remains routable and does not receive an Unroutable teardown; update consume_broadcast or dispatch only if the test exposes the described all-tainted failure.
🤖 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.
Nitpick comments:
In `@rs/moq-net/src/model/origin.rs`:
- Around line 618-647: Add a regression test for consume_broadcast covering a
shared-origin standby joining a relay already carrying the broadcast from its
peer. Build the topology with the shared origin, relay, active peer route, and
standby subscriber, then assert the standby remains routable and does not
receive an Unroutable teardown; update consume_broadcast or dispatch only if the
test exposes the described all-tainted failure.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 1a36e294-d829-47ad-8854-c8fbb192f01f
📒 Files selected for processing (7)
doc/bin/cli.mddrafts/draft-lcurley-moq-lite.mdrs/moq-cli/src/args.rsrs/moq-cli/src/main.rsrs/moq-net/src/lite/publisher.rsrs/moq-net/src/model/broadcast.rsrs/moq-net/src/model/origin.rs
That's not true, we always announce-interest to every peer. One of the problems is that we currently announce the best route to a peer, and we purposely skip doing it if the peer is in the hop list. If there's two valid routes (relay with 2 connections, each advertising a different "best" route), then we can fail to publish the better route to the peer. That being said, it doesn't explain the failure you're seeing, as relay A/B should both try to announce to each other.
I think we should only advertise a single route to a peer. Not only does this prevent explosive growth, but it also matches how SUBSCRIBE cannot choose a route. Like if we advertised 10 routes to a peer... it has no way of picking which one to use.
Yeah, we should splice if the first hop is shared.
Unit tests for both. |
|
@t0ms both publishers MUST be publishing identical content when using the same origin/name. If they're not, like it's two different publishers slurping the same TS feed, then the subscribers MUST get a signal to reinitialize on route change. A few ways of doing this:
|
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
doc/bin/cli.md (1)
102-108: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftDo not document failover as guaranteed before the two-relay path is fixed or covered by end-to-end testing.
The supplied PR context reports a two-relay drill where no standby route arrived and playback froze after the active publisher stopped. Either resolve that path and add a passing regression test, or qualify this documentation instead of promising uninterrupted viewer failover.
🤖 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 `@doc/bin/cli.md` around lines 102 - 108, Qualify the failover claims in the “Redundant Publishers (1+1)” documentation so uninterrupted viewer continuity is not presented as guaranteed. Alternatively, only retain the current guarantee after fixing the two-relay standby-route path and adding a passing end-to-end regression test covering active-publisher failure.
🤖 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 `@doc/bin/cli.md`:
- Around line 110-115: Update the relay splice timing statement in the
sharing-id documentation so it consistently states that failover or splicing
occurs at a group boundary, matching the earlier documented behavior. Preserve
the surrounding requirements about identical tracks, content, and aligned group
sequences.
In `@drafts/draft-lcurley-moq-lite.md`:
- Around line 293-295: Clarify that origin or peer-identity filtering applies
only when a nonzero, declared identity exists: in
drafts/draft-lcurley-moq-lite.md:293-295 update announcement selection, :306-307
update subscription-source selection, and :704-705 update ANNOUNCE_REQUEST
behavior; in drafts/draft-lcurley-moq-relay-hops.md:168-174 state that an empty
RELAY_HOPS value disables peer-identity filtering.
- Line 308: Update the paragraph’s “Exclude-Hop filter” reference to
“declared-origin filter,” preserving the explanation that the filter prevents
subscription cycles by removing candidates containing the subscriber’s own Hop
ID. Do not refer to the removed wire field.
In `@js/net/src/lite/announce.test.ts`:
- Around line 109-123: Update the AnnounceRequest test to assert reader.done()
after each decode for both draft-04/05 iterations and the draft-06 case,
ensuring the entire encoded frame is consumed while preserving the existing
excludeHop assertions.
---
Outside diff comments:
In `@doc/bin/cli.md`:
- Around line 102-108: Qualify the failover claims in the “Redundant Publishers
(1+1)” documentation so uninterrupted viewer continuity is not presented as
guaranteed. Alternatively, only retain the current guarantee after fixing the
two-relay standby-route path and adding a passing end-to-end regression test
covering active-publisher failure.
🪄 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: 1292563c-b1c7-46c4-a666-4b6a3d600018
📒 Files selected for processing (18)
doc/bin/cli.mddrafts/draft-lcurley-moq-lite.mddrafts/draft-lcurley-moq-relay-hops.mdjs/net/src/lite/announce.test.tsjs/net/src/lite/announce.tsjs/net/src/lite/connection.tsjs/net/src/lite/setup.test.tsjs/net/src/lite/setup.tsrs/moq-cli/src/args.rsrs/moq-native/tests/broadcast.rsrs/moq-net/src/client.rsrs/moq-net/src/lite/announce.rsrs/moq-net/src/lite/publisher.rsrs/moq-net/src/lite/session.rsrs/moq-net/src/lite/setup.rsrs/moq-net/src/lite/version.rsrs/moq-net/src/model/origin.rsrs/moq-net/src/server.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- rs/moq-cli/src/args.rs
- rs/moq-net/src/model/origin.rs
…election A relay announced only its single globally-best route per path, filtered per peer, so a standby publisher behind another relay was never presented to the node serving the active source; when that source died, the origin had nothing to reselect onto and playback froze (#2461). Even if the standby had been advertised, subscriptions always dispatched to the global active source, so a failover pull from the peer the active chain flows through would have looped. Announce and dispatch now share one selection: the best route whose hop chain excludes the requesting peer. The front mirrors its route table onto the spliced broadcast; each announce stream picks per exclude_hop (a peer inside the serving chain receives the best standby instead of nothing), and subscriptions from that peer are served by the same exclusion, which keeps advertised chains truthful and makes the exclude-hop filter loop-free at any cycle length. Content identity is the first hop: sources with a different first hop park until the incumbent ends instead of silently splicing, matching the session layer's restart rule. The warm cost discount now applies only to the serving route. moq-cli gains --origin to pin the publisher id, which is how 1+1 chains declare their content interchangeable. No wire format change; the behavioral rules are specified in draft-lcurley-moq-lite in the same change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…UNCE_REQUEST Review feedback: the publisher learned the peer's origin id from the first ANNOUNCE_REQUEST's exclude_hop, which is per-stream metadata and absent for a peer that never opens an announce stream. Each endpoint now declares its Hop ID in an additive SETUP Origin parameter (0x5), filled in from the session's attached origin handles, and the serve-side split-horizon reads it from the shared peer-SETUP slot. Old peers ignore the unknown parameter. Also per review: --origin validation uses anyhow context, the cli.md section anchor is fixed, and a pre-existing em dash in the draft's announce prose is replaced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ressions lite-06-wip removes ANNOUNCE_REQUEST's per-stream Exclude Hop; the SETUP Origin parameter is the session-wide identity that filters announcements and subscriptions alike. lite-04/05 keep the field on the wire. js/net mirrors both: AnnounceRequest carries excludeHop only on draft-04/05, and the client declares its origin in SETUP so relays keep suppressing reflected announces on lite-06. Unit tests for both findings from the #2461 e2e drill: a same-publisher standby attaching after the announce stream started goes out as a fresh announce to the excluded peer (and the active dying afterward is silent there), and a same-first-hop local standby joining a carrying front splices the live subscription instead of tearing it down. The dispatch test now also asserts the via-peer source receives no track request, which is the model-level image of "never forward a SUBSCRIBE back to the peer the data came from". A serving-flag flip with identical hops and cost no longer emits a redundant restart. Sharing an origin id is now documented as a promise: identical tracks with aligned group sequences, or use distinct ids and get replacement semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… round The broadcast_route_migration integration test encoded the pre-identity contract: two publishers with different first hops migrating silently. Under the first-hop content-identity rule that is a replacement, not a migration, so the test now stamps a shared publisher id on both routes, which is the documented way to declare interchangeable content. This is what CI caught; local `just check` does not run the moq-native integration binaries. Review: cache the publisher's excluded origin handle (the peer sends exactly one SETUP per session, so the exclusion never changes). Update draft-lcurley-moq-relay-hops to match the lite model: the RELAY_HOPS Setup Option value now declares the endpoint's own Hop ID, replacing the EXCLUDE_HOP parameter; advertisement selection and subscription serving are per session, excluding the peer's declared id; same-first-entry advertisements are interchangeable (redundant publishers MAY share a Hop ID) while differing origins never splice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebase onto main: #2506 extracted the publisher test doubles into a shared test_transport module, so spawn_announce now builds its stream from the Log-based SinkSend. Review round: cli.md states the splice invariant precisely (relays may switch same-id sources whenever routing prefers another, splicing at the next group boundary); both drafts exempt a peer that declared no origin from announcement and serving exclusion (an unknown Hop ID of 0 never matches); the lite draft's loop-freedom prose names the declared-origin filter instead of the removed Exclude Hop; the js AnnounceRequest test asserts full frame consumption per version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fdcab8f to
77c92fc
Compare
|
thanks for adopting the drill and fixing both findings at the model layer — on the rebased head the live two-relay drill still doesn't fail over, under lite-05 and lite-06-wip relay A (serving the local active source) never opens an announce-interest toward relay B for the broadcast, so relay B's excluded_peer_receives_the_standby path never actually fires on the wire so the model tests aren't yet exercising the live standby-pre-positioning / splice path. is the intended design that an active-serving relay unconditionally holds peer-advertised standby routes for broadcasts it serves, or is subscriber-multi-homing still the single-homed path? |
|
Finding 2 still reproduces live despite its new passing model test (test_standby_join_splices_live_subscriber): the shared---origin sub3 on the carrying relay still dies Unroutable code=30 when the local standby joins — i.e. the model test and the live dispatch diverge |
A standby attaching to a live front wins dispatch the moment it joins (one local hop beats two remote ones), which is before a real publisher has created every track: `moq import` announces its broadcast on connect and only creates each track once its demuxer reaches it. `serve_track` re-spliced onto the newcomer, took the rejection as a strike, retried the same source three times back to back, and aborted the logical track as `Unroutable`, tearing down a subscription the incumbent route was happily serving. The aborted entry then stayed in `spliced.tracks` forever, so the name was dead for the life of the front even once the publisher did create it. Whether a source carries a given track is a per-track property, so a refusal now rules that source out of that track only. `FrontState::serve_route` picks the front's active source unless this track's refusals rule it out, then the next-best route; only a sweep in which every attached source refuses spends a strike, and the sweep clears the refusals so a publisher that has since created the track is re-tried. The served route leaving the table closes a sweep too, which is the failover case. `broadcast::Consumer::track` drops an aborted spliced entry the way the plain lookup already reclaims a closed one, so the verdict belongs to the sources attached at the time rather than to the name. Found running the two-relay 1+1 drill from #2473: with the fix the drill passes end to end (the standby subscriber survives the join, and the subscriber on the far relay fails over once the dead publisher's session times out). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Picked this up and ran your drill against the PR head with two real Finding 1: the standby does reach the peerRelay A's log, the instant pubB joins relay B: Relay B selects the standby for the excluded peer and sends it; relay A attaches it as a second route with no parking warning. Before pubB joins, relay B logs What the drill actually hits is its own timeline. Removing that and finding 2 (video-only source, so the standby has its one track ready at connect; window extended past the timeout):
So the mesh half of #2461 does work end to end here. On the detection latency: a relay can't do better than the QUIC keep-alive today. It has no idea whether frames are supposed to be flowing for a given broadcast, so it can't treat silence as failure the way an application that knows its own cadence could; it has to wait for the transport to declare the peer gone. Lowering the default idle timeout (10s or so) is probably the right call and we may do it, but it has bitten us before, so it's a separate change rather than something to rush into this PR. If you want a faster drill in the meantime, Finding 2: real, pre-existing, fixed in this PRReproduced exactly as you described, and the root cause is one layer below routing. The standby wins dispatch the moment it attaches (one local hop beats two remote ones), which is before a real publisher has created every track:
Two notes on attribution: The fix, in this PR: whether a source carries a given track is a per-track property, so a refusal rules that source out of that track only. Two regression tests cover it: With both in, your original drill (audio+video, window extended past the idle timeout) passes end to end: sub3 survives pubB's join and keeps growing, and sub1 fails over. Relay logs show exactly one Your questions
(written by Opus 5) |
…llback The split-horizon check decided whether a requester could share the front by looking at the active route alone, at resolve time. Neither half held. The front picks a source per track and re-picks on failover, so a route tainted for a peer is one the front may serve them from even while the active route is clean: the per-track fallback reaches it immediately, and `reselect` swinging onto it reaches it later. Either way the peer is served from a chain that flows through them, which is reflected media at best and a subscription cycle at worst. Share the front only when no attached route is tainted for the requester, and pin them to the best clean source otherwise. Resolving also conflated "announced, but every route loops back through you" with "nothing here", so an excluded path fell through to the origin's dynamic handler. A handler resolves paths with no route chain to check, so that is a way around the split horizon rather than an answer to it. `Resolved` names the three outcomes; only `Missing` consults a handler, and `Excluded` is `Unroutable`. Nothing registers an origin-level handler today, which is why `test_dispatch_all_tainted_unroutable` passed on the absence of one rather than on the exclusion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cate-announcements-34ef49 `main` moved the lite test doubles into a shared `lite::test_transport` module (#2513), giving `SinkSend` a private `gate` field. The merge is textually clean but does not compile: this branch's announce-stream helper still builds the double with a struct literal. Point it at `SinkSend::new` like the other call site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@drafts/draft-lcurley-moq-lite.md`:
- Line 1322: Update the Hop IDs and origin-based announcement filter paragraph
to remove the claim that publishers are not required to honor the filter.
Preserve the privacy disclaimer by stating that the filter is for loop
avoidance, is not access control, and must not be relied upon to hide
broadcasts; keep the normative subscription requirement consistent with the rule
referenced near the Origin Parameter section.
In `@rs/moq-net/src/model/broadcast.rs`:
- Around line 431-439: Update set_routes to explicitly clear state.route with
the crate’s no-route value when routes is empty, incrementing route_epoch when
this changes, while preserving the existing active-route update for non-empty
tables. Add a regression test covering a non-empty to empty transition and
verifying Consumer::route() and poll_route_changed() no longer expose the
detached route.
🪄 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: b5ea7fc3-aa19-4546-84ed-010eba791385
📒 Files selected for processing (21)
doc/bin/cli.mddrafts/draft-lcurley-moq-lite.mddrafts/draft-lcurley-moq-relay-hops.mdjs/net/src/lite/announce.test.tsjs/net/src/lite/announce.tsjs/net/src/lite/connection.tsjs/net/src/lite/setup.test.tsjs/net/src/lite/setup.tsrs/moq-cli/src/args.rsrs/moq-cli/src/main.rsrs/moq-native/tests/broadcast.rsrs/moq-net/src/client.rsrs/moq-net/src/lite/announce.rsrs/moq-net/src/lite/publisher.rsrs/moq-net/src/lite/session.rsrs/moq-net/src/lite/setup.rsrs/moq-net/src/lite/version.rsrs/moq-net/src/model/broadcast.rsrs/moq-net/src/model/origin.rsrs/moq-net/src/model/resume.rsrs/moq-net/src/server.rs
🚧 Files skipped from review as they are similar to previous changes (16)
- rs/moq-net/src/lite/version.rs
- js/net/src/lite/connection.ts
- js/net/src/lite/setup.test.ts
- js/net/src/lite/announce.test.ts
- doc/bin/cli.md
- rs/moq-cli/src/main.rs
- rs/moq-net/src/server.rs
- rs/moq-cli/src/args.rs
- js/net/src/lite/announce.ts
- rs/moq-net/src/lite/session.rs
- rs/moq-net/src/lite/setup.rs
- rs/moq-native/tests/broadcast.rs
- js/net/src/lite/setup.ts
- rs/moq-net/src/lite/announce.rs
- rs/moq-net/src/lite/publisher.rs
- rs/moq-net/src/model/origin.rs
The Security Considerations paragraph said a publisher "is not required to honor" the origin-based announcement filter, which contradicts the Announce section: honoring it for both advertisement and dispatch is a MUST, and loop freedom of any cycle length rests on it. The point being made was about privacy, not conformance, so say that instead: a subscriber cannot verify the filter was applied, so it hides nothing. Also state the non-empty contract on `set_routes` where the function lives rather than only at its one call site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cate-announcements-34ef49 `main` replaced the pinned-sleep-plus-`fired` timer scaffolding with `kio::time::Deadline` (#2536) in the two loops this branch also edits. Took main's shape in both and kept this branch's additions on top: the per-track `skip` closure in `serve_track`, and the route-table wording in the announce loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Confirmed on Finding 1 — withdrawn, it was our drill. A hard 30–33 s across runs, i.e. one idle timeout — detection dominates and the reselect Finding 2 — fixed. One caveat worth a look before this landsThe standby join is not transparent. Consistent across every run on this head. Much better than being torn down, but a Separate, possibly unrelatedKilling DrillSubmitted as #2545 — |
…cate-announcements-34ef49 `main` reworked draft-lcurley-moq-relay-hops (#2546) in the sections this branch rewrites. Took main's tightening everywhere it is orthogonal to the Setup-Option change: the PROTOCOL_VIOLATION on a missing HOP_PATH, the "no generic skip rule" paragraph and the strip-on-forward rule, the relocated synthesize-an-upstream-Hop-ID rule (now stated once, in Relay Behavior), and the simplified IANA preamble. Kept this branch's normative change on top: the RELAY_HOPS Setup Option value declares the endpoint's own Hop ID, EXCLUDE_HOP is gone, and Path Selection is per session with dispatch bound to the same rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cate-announcements-34ef49 `main` landed the Exclude Hop removal on its own (#2550), so both sides now drop the field. Converged on main's shape wherever the two agree: its `has_exclude_hop` / `hasExcludeHop` gates (this branch's `has_request_exclude_hop` is deleted, its one call site repointed), its encode/decode form, and its per-version tests, which cover the same ground plus a wire-size assertion. The JS announce codec and its tests are now identical to main's. What remains is this branch's addition on top: the SETUP `Origin` parameter carries that identity session-wide and filters subscriptions as well as announcements. Kept in both drafts, and in the moq-lite changelog alongside main's two bullets rather than duplicating them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The data-plane split horizon was decided once, when the peer resolved the broadcast: a front with no route tainted for them handed out the shared front, and a route back through them attaching afterwards could be adopted underneath their live subscription. New subscribes re-resolve and are safe, so the window belonged to long-lived ones, which is exactly what a relay-to-relay subscription is. Resolving now registers the peer on the front for as long as it holds the shared handle (`ExclusionGuard`, refcounted, released when the last clone of the `broadcast::Consumer` drops), and route selection prefers routes clean for every registered peer. The registration is per front rather than per origin so one peer subscribing cannot distort routing for unrelated broadcasts, and selection falls back to a tainted route when no clean one remains: starving readers the route is good for is worse, and a peer whose only path runs back through itself has nothing to be served from anyway, so it re-resolves to Unroutable on its next request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Retested both of my post-merge notes on merged Retracting the 8–9 s standby-join stallIt still reproduces, but it is my harness, not your routing. Both of my publishers replay
Your switch is in fact immediate: relay B logs For the record, of the four things I reported across this work, two were real (the exporter's The graceful source exit does stand, and I have a cleaner reproMy first observation of this was contaminated: I had killed Relay A's log shows why, and it is not a reselect that failed — no reselect is attempted: The tracks complete, so the broadcast is treated as finished rather than as a source that left, I can see the argument that this is correct semantics — a finished broadcast is finished, and Happy to leave it here if you consider it working as intended — just flagging it, since it is the |
Addresses #2461 (the mesh-propagation half; the remaining pieces are listed at the bottom, so this deliberately does not auto-close the issue).
Root cause
Active/active (1+1) source failover never worked across a mesh because the standby route was never presented to the node that needed it, and could not have been served even if it were:
broadcast.route()) and skips the announce entirely when that chain contains the peer'sexclude_hop(prepare_active_hops). So relay B, actively carryingpubA → relayA → relayB, advertised nothing to relay A even while holding a perfectly good standbypubB. Relay A's origin table then had nothing toreselectonto when pubA died: permanent freeze, exactly the repro in Active/active source failover: a standby publisher's route is never presented to the relay serving the active source (mesh), so a dead source is not failed over — even withmoq-lite-06cost routing #2461. Cost routing (feat(net): route by cumulative cost on lite-06 announcements #2424) could not help because pricing cannot rank a route that is never propagated.entry.publisher != publisher), but the same event across two sessions silently attached as a splice-compatible standby inattach_source, with no identity check at all.The fix
One selection function, used for both announcing and serving: the best route whose hop chain excludes the requesting peer.
lite/publisher.rs): the front mirrors its full route table (metadata only, preference-ordered, serving route first) onto the spliced broadcast (broadcast::Producer::set_routes, crate-private). Each announce stream selects from the table with its peer'sexclude_hop, so a peer the serving chain flows through receives the best standby instead of nothing. The winner changing per peer rides the existing restart machinery; swinging into/out of existence rides the existing filtered-announce arms, which already handled per-peer subsetting.model/origin.rs): sessions resolve broadcasts through a crate-privateorigin::Consumer::excluding(peer). The peer's identity arrives in its SETUP via a new additiveOriginparameter (0x5), declared by each endpoint at session start, so the exclusion applies even to sessions that never open an announce stream. The shared front is handed out only when no attached route is tainted for the requester, since the front picks a source per track and re-picks on failover, and resolving registers that peer on the front (ExclusionGuard, refcounted, released when its lastbroadcast::Consumerclone drops) so route selection keeps avoiding routes through them for as long as they read it, falling back only when no clean route remains; otherwise the lookup returns the best clean source directly. When only tainted routes exist the subscription is unroutable, and resolving distinguishes that from an unannounced path (Resolved::{Found, Excluded, Missing}) so a dynamic handler is never asked to route around the exclusion. Together with truthful advertised chains this is path-vector loop freedom: any would-be cycle surfaces the requester's own id inside a candidate chain, where the filter removes it — at any cycle length, which plain immediate-peer split horizon cannot guarantee.model/origin.rs):attach_sourcenow rejects a source whose first hop differs from the incumbent's. The rejected source parks invisibly and takes over the path once the incumbent ends (first wins, successor succeeds it), instead of silently splicing unrelated content into live subscribers. The session-level restart rule was already first-hop-keyed; this makes the cross-session path agree with it.run_sourceapplies the same check to route updates that change their own first hop.SentRoute.serving). A standby advertised to an excluded peer keeps its accumulated cost, because serving that peer means opening a fresh ingest. (This also fixes a busy-spin the naive version would have had: the demand watch armed on any nonzero sent cost, expecting the recompute to discount it to zero, which a standby never does.)model/origin.rs,model/broadcast.rs): a standby wins dispatch the moment it attaches (one local hop beats two remote ones), which is before a real publisher has created every track.serve_trackused to re-splice onto the newcomer, count the rejection as a strike, retry the same source three times back to back, and abort the logical track asUnroutable, tearing down a subscription the incumbent route was serving; the aborted entry then stayed inspliced.tracksforever, so the name was dead for the life of the front. Whether a source carries a given track is a per-track property, so a refusal now rules that source out of that track only:FrontState::serve_routeprefers the active source, falls back to the next-best route this track has not been refused by, and only a sweep in which every attached source refuses spends a strike (the sweep then clears the refusals, so a publisher that has since created the track is re-tried). The served route leaving the table closes a sweep too, which is the failover case.broadcast::Consumer::trackdrops an aborted spliced entry the way the plain lookup already reclaims a closed one. This is a pre-existing bug (serve_trackis otherwise untouched here, and it reproduces onmain), but it is what blocked the end-to-end drill, so it lands with the mechanism it blocks.moq --origin <id>(moq-cli): pins the process origin id (default: fresh random per run). Redundant 1+1 publishers share an id to declare their content interchangeable; a crash-restarted encoder keeps getting a fresh id and is correctly treated as new content. The relay already had this via--cluster-id. Sharing an id is a promise that both publishers produce exactly the same tracks with aligned group sequences (now spelled out indoc/bin/cli.md); independent publishers must use distinct ids, and the newcomer waits as a replacement.With this, the #2461 repro works end to end: relay B advertises
[pubB]to relay A; pubA dies; relay A splices to the standby at a group boundary; the restart that propagates keeps the first hop, so no downstream node tears anything down; relay A's subscription to relay B is dispatched to pubB even while relay B's own table still holds the stale via-relay-A entry (no loop window).Wire / draft
Two wire changes: a SETUP
Originparameter (0x5) declaring each endpoint's Hop ID at session setup (additive: SETUP receivers ignore unknown parameter IDs, so it is backward-compatible with deployed lite-05 peers), and lite-06-wip drops ANNOUNCE_REQUEST's per-streamExclude Hopin its favor (lite-06 is unreleased and negotiates only by explicit opt-in, so this breaks no published contract and the PR still targetsmain). Lite-04/05 keep the field on the wire and behave exactly as before. The normative changes are specified indrafts/draft-lcurley-moq-lite.mdin this PR: the Origin parameter, per-subscriber advertisement selection, the serve-by-the-same-rule requirement, shared-first-hop interchangeability (redundant publishers MAY share a Hop ID), and the serving-only scope of the carrying discount.drafts/draft-lcurley-moq-relay-hops.md(the IETF moq-transport extension) is updated to match: the RELAY_HOPS Setup Option value now declares the endpoint's own Hop ID, replacing the EXCLUDE_HOP parameter, with the same per-session selection, serve-by-the-same-rule, and shared-first-entry interchangeability semantics.Behavioral changes to existing public APIs (no signature changes)
origin::Producer::create_broadcast: a second source at a path with a different first hop no longer joins as a standby; it parks until the incumbent ends. Sources with the same first hop (or both hopless/local) behave exactly as before.Cross-package sync
js/net: mirrored the wire changes.AnnounceRequestcarriesexcludeHoponly on draft-04/05, and the client declares its session origin via the SETUPOriginparameter so relays keep suppressing reflected announces on lite-06. No selection/dispatch logic to mirror: JS is a leaf with no multi-route origin table.moq-ffi/ bindings: no surface change. Exposing an origin-pin knob to bindings publishers is a natural follow-up but not needed for the core mechanism.moq-relay: no change;--cluster-idalready pins the relay origin.doc/bin/cli.mdgains--originand a "Redundant Publishers (1+1)" section.Tests
test_publisher_mismatch_parks: different first hop parks, takes over on incumbent end as unannounce + announce (also regression-covers the orphaned-leaf bug found while writing it: a parked source now re-resolves its tree leaf per attach attempt, since the incumbent's teardown can prune it).test_exclusion_holds_when_a_tainted_route_attaches_later: the peer resolves while the table is clean, a cheaper route back through them attaches after, and the front stays off it until the peer's last handle drops.test_per_track_fallback_respects_exclusion: the clean route is active but cannot carry the track, so the front's fallback is the peer's own route; the peer is pinned to the clean source and never reaches it.test_exclusion_survives_failover_onto_a_tainted_route: the peer resolves while a clean route is active, that route dies, and its subscription still never migrates onto the route through itself.test_excluded_path_never_reaches_the_dynamic_handler: an all-tainted announced path isUnroutablewith a live handler registered, while a genuinely missing path still falls back to it.test_dispatch_excludes_requester: tainted-for-the-peer active → peer pinned to the clean source, everyone else on the shared front.test_dispatch_all_tainted_unroutable: all routes through the requester →Unroutable.excluded_peer_receives_the_standby(wire): peer in the active chain receives the standby chain at its undiscounted cost while an ordinary peer receives the active chain at the warm discount.retracts_when_route_swings_through_peer(wire): route swinging into the peer's chain →ANNOUNCE_END; swinging back → freshANNOUNCE_START.standby_attach_announces_to_excluded_peer(wire): a relay carrying via its peer initially advertises nothing to them; a same-publisher standby attaching later goes out as a fresh announce, and the active source dying afterward is invisible on that stream (the standby is already advertised). Unit shape of the e2e drill's finding 1.test_standby_missing_track_keeps_incumbent: the standby refuses the track more times than the strike budget (a publisher whose demuxer has not reached it yet); the live subscription keeps flowing from the incumbent instead of aborting, and splices onto the standby once the incumbent dies and the standby has the track.test_unservable_track_retried_by_a_later_request: a track every source refuses still aborts asUnroutable, but a later request re-asks the sources instead of resolving from the cached corpse.test_standby_join_splices_live_subscriber: a local same-first-hop standby joining a front that is carrying from a peer splices the live subscription at the group boundary (finding 2, with the aligned content that a shared origin id requires).test_dispatch_excludes_requesternow also asserts the via-peer source receives no track request at all: a track request on that source is exactly what a session would forward upstream as a SUBSCRIBE, so this is the "never subscribe back to the peer the data came from" guarantee.announce_request_exclude_hop_by_version+ js mirrors: the field round-trips on lite-04/05 and is absent from lite-06's wire image.broadcast_route_migration(caught by CI, notjust check, which skips the moq-native integration binaries): it encoded the pre-identity contract of silently migrating across different first hops, and now stamps a shared publisher id, which is the behavior the migration feature promises.Not in this PR
Follow-ups toward the full #2461 acceptance test: checking in the two-relay integration drill (the issue authors offered their
cluster_failover.sh; it needs a timeline longer than the QUIC idle timeout, see below), an origin-pin knob for the bindings/gateways (moq-ffi, gst, rtmp/srt ingest), and subscriber multi-homing (option B in the issue).Failover latency
A relay cannot tell a dead publisher from a quiet one: it does not know whether frames are supposed to be flowing for a given broadcast, so it waits for the transport to declare the peer gone. With an ungracefully killed publisher (no CONNECTION_CLOSE) that is the QUIC idle timeout, 30s by default, and the failover cannot start before it. Lowering that default is worth doing but is a separate change with its own history;
--server-quic-idle-timeoutmoves it in the meantime.(Written by Fable 5, extended by Opus 5)