Skip to content

test(failover): two-relay 1+1 source-failover drill - #2545

Closed
t0ms wants to merge 1 commit into
moq-dev:mainfrom
t0ms:test/two-relay-failover-drill
Closed

test(failover): two-relay 1+1 source-failover drill#2545
t0ms wants to merge 1 commit into
moq-dev:mainfrom
t0ms:test/two-relay-failover-drill

Conversation

@t0ms

@t0ms t0ms commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

The end-to-end drill from #2461, cleaned up for the tree as just test failover.
It is the live counterpart to the routing unit tests in model/origin.rs: two
real relays, real publishers whose tracks are created lazily by the demuxer, and
the QUIC idle timeout in the loop.

pubA ──▶ relayA(:4443) ◀──cluster──▶ relayB(:5443) ◀── pubB
          ▲   ▲                          ▲
        sub1 sub2                       sub3

sub3 is load-bearing rather than decorative: it forces relayB to carry the
broadcast via relayA, which is what makes relayA a hop in relayB's own route and
therefore the peer that per-peer announce selection has to treat specially.
Without it the interesting case never arises.

CHECK 1 — failover. Kill pubA; sub1 on relayA must resume, which requires
relayB to have already advertised the local pubB standby to relayA.

CHECK 2 — standby join. sub3 on relayB must survive pubB joining. This is
the case a model-level standby cannot reproduce: it accepts every track request
immediately, whereas moq import announces on connect and creates each track
only once its demuxer reaches it, so a freshly attached standby legitimately
cannot serve some tracks yet.

Results on #2473 (head cc11cbaf, release, macOS)

Both checks pass, repeatably:

CHECK 1 failover: sub1 at kill(t=32)=5293892 -> t=81=8647624
PASS: sub1 resumed at t=65, 33s after the kill (+3353732 bytes)
CHECK 2 standby join: sub3 at join(t=10)=1624696 -> t=31=3647388
PASS: sub3 survived pubB's join (+2022692 bytes)
WARN: sub3 stalled 9s at the standby join before recovering

sub1 resumes 30–33 s after the kill across runs, which is one QUIC idle
timeout — so detection dominates and the reselect itself is essentially free.

The standby join is not yet transparent: sub3 stops for 8–9 s before
recovering at full rate. That is a large improvement on being torn down, but it
is still a visible outage for a viewer of a relay that is only carrying the
broadcast, so the drill measures it and prints a WARN rather than hiding it
behind a pass. Details in #2473.

Two things about the harness that are easy to get wrong

Both cost me a wrong conclusion, so both are commented in the script.

The timeline is derived, not hard-coded. Killing a publisher sends no
CONNECTION_CLOSE, so the relay keeps serving the dead source until the QUIC
idle timeout expires and logs nothing at all in between. A grading window shorter
than that budget cannot pass on any build — my original drill killed at t=22 and
graded at t=43, i.e. 21 s into a 30 s timeout, which is how I first reported a
failover that did not exist. --idle lowers the timeout and the window together.

The publisher pipeline is SIGKILLed in one pass. Killing tsp first leaves
moq import reading a truncated stream plus EOF, so it shuts its broadcast down
cleanly, the relay unannounces immediately, and you grade a graceful detach
instead of a source failure. Worth knowing that the two paths differ sharply: on
the clean-detach path sub1's export ts dies with TS track layout changed after PAT/PMT was emitted: '0.avc3' removed instead of failing over, even with
the standby announced. I have not chased that one down; noting it in case it is
interesting.

Dependency

Requires moq --origin, which arrives with #2473, so this should land with or
after it. On a build without the flag the drill exits with a diagnostic rather
than reporting a misleading zero-byte failure. Verified: on main it stops at
the preflight; on #2473's head both checks pass.

Not wired into the default just test aggregate, same as just test smoke.
Needs ffmpeg and TSDuck's tsp; the source clip is generated, with --source
to publish a real capture instead.

Made with Cursor

@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 @t0ms, 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 44baf7d5-d567-477c-8ca0-1fbd12723c3b

📥 Commits

Reviewing files that changed from the base of the PR and between 5f8deb8 and e8d4d19.

📒 Files selected for processing (3)
  • test/failover/README.md
  • test/failover/run.sh
  • test/justfile
🚧 Files skipped from review as they are similar to previous changes (3)
  • test/failover/README.md
  • test/justfile
  • test/failover/run.sh

Walkthrough

Adds a two-relay active/active failover acceptance test with configurable source, idle timeout, ports, profile, and log retention. The harness builds required binaries, starts a meshed relay pair, launches three subscribers and two origin-tagged publishers, kills publishers on a timed schedule, samples output sizes, and grades failover and standby-join survival. It also adds README documentation covering topology, timing, prerequisites, usage, and debugging, plus a just test failover recipe.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: a two-relay source-failover drill.
Description check ✅ Passed The description directly matches the added failover drill and its checks.
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.
✨ 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.

@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: 6

🤖 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 `@test/failover/README.md`:
- Line 8: Remove all em dashes from the prose in test/failover/README.md,
including the referenced passages around “this covers the wiring,” “for that
work,” “expires,” “reselect itself,” and “behave very differently.” Reword or
punctuate those sentences with commas, periods, colons, or parentheses while
preserving their meaning.
- Around line 14-18: Add the text language identifier to the fenced diagram code
block in the README, changing the opening fence to use text while preserving the
diagram content unchanged.
- Line 93: Update the prerequisites list in the failover README to include pkill
alongside the tools already required by run.sh’s require_tools function,
including cargo, ffmpeg, curl, pgrep, and TSDuck’s tsp.

In `@test/failover/run.sh`:
- Around line 24-27: Update the CHECK 2 comment in the standby-join test to
describe the current expected behavior and why sub3 on relayB must survive pubB
joining, removing the historical reference to the former Unroutable subscription
failure.
- Around line 38-39: Update the runtime estimates in the comments for the
default and --idle 10s invocations in run.sh to match the documented timeline:
approximately 90 seconds for the default 30-second budget and approximately 70
seconds for the 10-second budget.
- Around line 76-82: The IDLE_BUDGET derivation in the failover script
incorrectly strips only a trailing “s” and cannot parse humantime durations.
Replace this conversion with the existing duration-class or equivalent humantime
parser to convert valid IDLE values such as milliseconds, compound durations,
and bare seconds into whole seconds before timestamp arithmetic; retain the
30-second fallback when IDLE is unset.
🪄 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: a743ed33-2395-4235-ab0e-20feef2542a4

📥 Commits

Reviewing files that changed from the base of the PR and between 16187e1 and c63a0d3.

📒 Files selected for processing (3)
  • test/failover/README.md
  • test/failover/run.sh
  • test/justfile

Comment thread test/failover/README.md Outdated
the surviving standby takes over.

This is the end-to-end counterpart to the routing unit tests in
`rs/moq-net/src/model/origin.rs`. Those cover the model; this covers the wiring —

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Em dashes used in prose — violates the no-em-dash guideline.

Found at "this covers the wiring —" (line 8), "for that work — it fails" (line 27), "expires — 30 s by default" (line 61), "reselect itself — on this checkout" (line 69), and "behave very differently — on a clean detach" (line 81).

✏️ Suggested rewording
-`rs/moq-net/src/model/origin.rs`. Those cover the model; this covers the wiring —
-two real `moq-relay` processes, real `moq import ts` publishers whose tracks are
+`rs/moq-net/src/model/origin.rs`. Those cover the model; this covers the wiring:
+two real `moq-relay` processes, real `moq import ts` publishers whose tracks are
 created lazily by the demuxer, and the QUIC idle timeout in the loop.
-> for that work — it fails on `main` for want of the flag, and both checks pass
+> for that work; it fails on `main` for want of the flag, and both checks pass
 > on `#2473`'s head.
-the dead source until the **QUIC idle timeout** expires — 30 s by default. The
+the dead source until the **QUIC idle timeout** expires (30 s by default). The
-not by the reselect itself — on this checkout `sub1` resumes 30–33 s after the
+not by the reselect itself: on this checkout `sub1` resumes 30–33 s after the
-detach rather than a source failure. Worth knowing because the two paths behave
-very differently — on a clean detach the subscriber's `export ts` currently dies
+detach rather than a source failure. Worth knowing because the two paths behave
+very differently: on a clean detach the subscriber's `export ts` currently dies

As per coding guidelines, "Do not use em dashes in code, comments, documentation, commit messages, or prose."

Also applies to: 27-27, 61-61, 69-69, 81-81

🤖 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 `@test/failover/README.md` at line 8, Remove all em dashes from the prose in
test/failover/README.md, including the referenced passages around “this covers
the wiring,” “for that work,” “expires,” “reselect itself,” and “behave very
differently.” Reword or punctuate those sentences with commas, periods, colons,
or parentheses while preserving their meaning.

Source: Coding guidelines

Comment thread test/failover/README.md Outdated
Comment thread test/failover/README.md Outdated
Comment thread test/failover/run.sh Outdated
Comment thread test/failover/run.sh Outdated
Comment thread test/failover/run.sh Outdated
Comment on lines +76 to +82
# Detection budget follows the relays' idle timeout when it is pinned, otherwise
# quinn's 30s default.
if [[ -n "$IDLE" ]]; then
IDLE_BUDGET="${IDLE%s}"
else
IDLE_BUDGET=30
fi

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check what format moq-relay's --server-quic-idle-timeout expects/parses.
rg -n "quic-idle-timeout" --type=rust -C6

Repository: moq-dev/moq

Length of output: 149


Avoid deriving IDLE_BUDGET when --idle uses human-time parsing.

--server-quic-idle-timeout accepts humantime durations, so 500ms, 1m30s, 30s and bare seconds can all be valid relay arguments. ${IDLE%s} turns those into non-seconds like 500m or 1m30, causing the numeric timestamp arithmetic to fail badly. Convert IDLE to whole seconds via the same duration class or a matching parser instead of stripping a trailing s.

🤖 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 `@test/failover/run.sh` around lines 76 - 82, The IDLE_BUDGET derivation in the
failover script incorrectly strips only a trailing “s” and cannot parse
humantime durations. Replace this conversion with the existing duration-class or
equivalent humantime parser to convert valid IDLE values such as milliseconds,
compound durations, and bare seconds into whole seconds before timestamp
arithmetic; retain the 30-second fallback when IDLE is unset.

t0ms added a commit to tdrapier-wbd/moq-mpegts-paper that referenced this pull request Jul 27, 2026
Re-tested source failover on the #2473 head: it works, bounded by one QUIC
idle timeout (30-33s), and recovery is complete and repeatable once the
publisher pipeline is killed atomically. This retracts our earlier
"inconsistent recovery" caveat, which came from a drill that killed tsp
first and so graded a graceful detach rather than a source failure.

Two gaps now documented: the standby join stalls carrying-relay viewers
8-9s, and a graceful source exit is not failed over at all (the subscriber
terminates on the catalog change). Both reported upstream; the corrected
drill is contributed as moq-dev/moq#2545.

Keeps the conclusion unchanged: failover is bounded, not hitless, so
receiver-side ST 2022-7 / IRD selection stays the broadcast-grade path.
Stands up two meshed relays, a redundant publisher pair sharing an origin id,
and three subscribers, then kills the active source and checks that the
standby takes over. The end-to-end counterpart to the routing unit tests in
model/origin.rs: real relays, real publishers whose tracks are created lazily
by the demuxer, and the QUIC idle timeout in the loop.

Two graded checks. Failover: sub1 on relayA must resume after pubA dies,
which requires relayB to have already advertised the local pubB standby to
relayA. Standby join: sub3 on relayB must keep its subscription when pubB
attaches locally, the case a model-level standby cannot reproduce because it
accepts every track request immediately whereas a real publisher has not
created every track yet.

The subscriber on relayB is load-bearing rather than decorative. It forces
relayB to carry the broadcast via relayA, which is what makes relayA a hop in
relayB's route and therefore the peer that per-peer announce selection has to
treat specially.

Three properties of the harness decide whether a run means anything, so each
is documented where it is set. The observation window is derived from the QUIC
idle timeout rather than chosen, because killing a publisher sends no
CONNECTION_CLOSE and the relay serves the dead source until that timeout
expires: a shorter window cannot pass on any build. The kill takes the whole
pipeline down in one pass, because killing tsp first lets moq import see EOF
and shut the broadcast down cleanly, which grades a graceful detach instead of
a source failure. And the standby joins early, because the two publishers
replay independent copies of the same clip from its start: their media
timelines differ by the join delay, so the subscriber's muxer waits for the
new source's timestamps to overtake the last ones it wrote, one second per
second of join delay. That wait belongs to the harness, not the relay, which
reselects in the same millisecond the standby connects.

On current main both checks pass: sub1 resumes 30 to 33s after the kill, one
idle timeout, so the reselect itself is essentially free.

Kept out of the default `just test` aggregate, like `just test smoke`.
@t0ms
t0ms force-pushed the test/two-relay-failover-drill branch from 5f8deb8 to e8d4d19 Compare July 28, 2026 07:44
@t0ms

t0ms commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on merged main and updated. Force-pushed as a single commit, so here is what changed
since the review.

One of the two things this drill reported was the drill's own fault, and it is gone. The
8 to 9 s stall at the standby join was not routing: the two publishers replay independent copies
of the same clip from its start, so the standby's media timeline lags the active one by its join
delay, and on the splice the subscriber's muxer waits for the new source's timestamps to overtake
the last ones it wrote. It scales one-for-one with the join delay (t=4 costs under 2 s, t=10 costs
9 s, t=20 costs 18 s) while the relay reselects in the same millisecond the standby connects.
Retracted on #2473. The drill now joins the standby at t=4 and only warns when a stall exceeds
what that offset explains, and the offset is documented next to the other two traps. Both checks
pass clean on merged main, no warning, sub1 resuming 33 s after the kill.

CodeRabbit's review, addressed:

  • --idle now rejects anything this script cannot do arithmetic on. It was stripping a trailing
    s, so 1m or 500ms would have silently mis-derived the observation window, which is the one
    thing this harness cannot afford to get wrong.
  • Em dashes removed. I had missed the house rule in AGENTS.md, sorry.
  • Diagram fence tagged text; pkill listed in the prerequisites.
  • Check 2 is now described by what it proves rather than by the bug it came from.
  • Runtime estimates corrected to match the timeline (~90 s default, ~70 s with --idle 10s).

Also exposed the join and kill times as FAILOVER_PRE / FAILOVER_KILLA, which is what made the
offset above diagnosable in the first place.

--origin is on main now that #2473 has merged, so the dependency note is historical; the
preflight check stays for anyone running this against an older checkout.

@kixelated

Copy link
Copy Markdown
Collaborator

With the timeline stuff, if you're publishing two separate broadcasts (ie. different timestamps, different codecs, different segments, etc) with the same name then failover is impossible. We need explicit cooperation from publishers to produce the exact same broadcast.

If they're not the same, then they could be published with different names. foo/primary and foo/backup for example. Either way the application needs to reinitialize everything on a switch.

@t0ms

t0ms commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Following up with data. We rebuilt the drill around one source feeding both publishers, since a 1+1 pair should be two views of one feed rather than two independent encodes. On current main (moq-relay 0.14.3):

  1. Byte-identical (one file duplicated into two co-started importers): the two subscribers' output is byte-for-byte identical up to the kill, confirming the publishers produce the same broadcast. After a hard kill, the subscriber reselects onto the standby ~31 s later (the 30 s idle timeout), continuity-clean TS, a single current group evicted; skipping to next buffered group.

  2. Mid-stream standby (shared feed; standby joins ~11 s after the primary, so its group numbering is offset): failover still succeeds and the TS is still continuity-clean; the exporter subscribes once, never re-subscribes, and skips to the standby's live edge.

So in our testing the requirement was a common source (same PMT/track layout, consistent PTS), not byte-identical segmentation, the consumer resumes at the new live edge rather than demanding group-number continuity. The switch is not hitless: the ~30 s outage is a PCR/PTS discontinuity (continuity-counter stays clean, but there's a content hole). Lowering --server-quic-idle-timeout to 10 s gives an ~11 s switch; 5 s was too aggressive and the switch didn't complete.

Is a lower idle default on the table?
Is receiver-side dual-subscribe the intended path for anyone who needs sub-second/hitless?

@kixelated

Copy link
Copy Markdown
Collaborator

So in our testing the requirement was a common source (same PMT/track layout, consistent PTS), not byte-identical segmentation, the consumer resumes at the new live edge rather than demanding group-number continuity.

So I think this requirement is quite difficult and out of scope.

The problem is that we lack information about what is continuous. The relay sees two broadcasts with the same name but different IDs and group numbering. A seamless resume is impossible from the relay's point of view because it's content agnostic; the bytes are either the same or they are different. We purposely don't want the relay to try to interpret the data to decide that these bitstreams are close enough so we could merge them by rewriting timestamps or something.

There are a few options to get the desired behavior you want:

  1. Produce explicitly compatible content. This is something elemental is focusing on, basically setting the group ID based on the wall clock timestamp of the encoder. Requires some annoying clock sync stuff and a lot of guarantees that encoder A produces compatible (doesn't have to be bit identical) content as encoder B.

  2. Have the clients resume. This is how @moq/watch works; we reinitialize the decoder/timestamps rather than assuming the new broadcast is a continuation of the previous one. It might be possible with export ts to optimistically try to stitch in the new broadcast by rewriting timestamps and validating the PMT/PAT is the same. But yeah it's gross and we'd need some way of aligning timestamps.

  3. Just putting this out there in case it's possible, but you could use the same broadcast with two routes. ie. one encoder, but you would publish the broadcast twice over separate routes (ex. satellite and ethernet). This should work today, as if original publisher uses the same origin ID then we'll seamlessly stitch tracks. The issue you're running into is that both publishers are explicitly saying they're NOT the same ID, so we have no idea if they're collaborating or competing. You might want collaboration (same source feed), but somebody else might want it to be a brand new broadcast using the same name (ex. kixelated starts streaming from a different PC).

2 is the type of thing you would do within a company with a lot of safeguards in place. It's difficult to do it generically, because even something like the moq binary version matters a lot and of course so does the encoder.

@kixelated

kixelated commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

and yeah just to clarify, using the same --origin <id> means you are explicitly stating you produce the same content (ex. like elemental's transcoders). If you actually produce different content, then that will blow stuff up.

@t0ms

t0ms commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Clear: the relay is content-agnostic, so a seamless merge of two independently-numbered broadcasts isn't something it can (or should) infer.

It's probably worth clarifying our actual architecture, it reframes your three options for our case.

How we run primary distribution. Everything is 1+1 active/active and doubled end-to-end, with the
two legs kept fully separate: two sources (same content) → two publishers → two relays → two subscribers → two
pacers, and two receivers (IRDs) do the hitless switch with ST 2022-7. The routes are permanently
provisioned, so we control the names/IDs. The load-bearing property for us is resilience of each
leg
and a make-before-break switch at the receiver. This is designed to minimise any stall if avoidable.
Given that, the failovers inside MoQ are nice-to-haves rather than requirements:

  • Subscriber → alternate relay is the one that matters most (and we get it by doubling the
    subscriber, i.e. a receiver watching both legs).
  • Relay switching from a dead publisher to a standby is welcome but not critical.
  • Publisher switching to a different upstream source is likewise a plus, not a must.

Most primary IP distribution today has little or no failover at all, so even the bounded behaviour is
an improvement, but the thing we actually rely on for "no stall" is the doubled chain + receiver-side
selection, not a relay-side reselect.

Your three options mapped to that:

  1. Compatible-content encoders (Elemental / wall-clock group IDs) removes the numbering discontinuity at the source. But it still doesn't remove the detection outage: a hard-killed source is ~one idle timeout of silence regardless of numbering. So it makes relay failover cleaner, not hitless. Useful long-term; not on our critical path.
  2. Client resumes / reinitialises (@moq/watch; optimistic export ts stitching) this is a
    re-init, i.e. still a gap, and it's a single-consumer story. Our receivers are doubled and merged
    downstream, so we'd rather run two hot legs than have one consumer reinitialise through a hole.
  3. Same broadcast, same --origin, two routes closest to our world and works today; it's
    effectively what our single-source drill exercised. The one caveat for us is that it couples the
    two legs at the relay, whereas we deliberately keep them independent and merge at the IRD (no
    shared-relay dependency). Still carries the detection gap on a hard loss.

The point we'd underline: none of the three gives no-stall on a hard failure — they each carry a
detection or re-init gap. Sub-second/hitless is a receiver-side make-before-break property, and crucially the
common source is precisely what makes that feasible (two legs that are the same broadcast → an IRD can
merge them). So we're aligned with you that relay reselect is a bounded enhancement, not the seamless
path.

On this PR specifically: the drill validates the shipped #2473 behaviour honestly — a bounded
route reselect on an ungraceful source loss, with a shared source and shared --origin,
continuity-clean but not hitless (a PCR/PTS discontinuity across ~one idle timeout; ~11 s at
--server-quic-idle-timeout 10s, and 5 s was too aggressive to complete). It's a test of that
behaviour, not a feature. Happy either way on disposition — land it as a regression test with its
scope stated that way, or close it now that the model is settled.
Your call; we don't want it
lingering if it's not useful to you.

One small question that would help the nice-to-have case for anyone using relay reselect without an
IRD: is a lower --server-quic-idle-timeout default on the table? ~10 s was reliable in our runs.

@kixelated

Copy link
Copy Markdown
Collaborator

One small question that would help the nice-to-have case for anyone using relay reselect without an
IRD: is a lower --server-quic-idle-timeout default on the table? ~10 s was reliable in our runs.

It used to be lower but I don't remember why I raised it.

And yeah, I think what you're saying makes sense. Something at the media layer should support splicing semi-related broadcasts, resuming tracks by rewriting timestamps. It's just difficult to make it generic, maybe if there's an explicit opt-in somewhere in the catalog that Broadcast A Track B is the same content as Broadcast C Track D even if the timestamps and codecs don't match.

@kixelated

Copy link
Copy Markdown
Collaborator

We've got unit tests for all of this behavior now, which is easier to maintain than manual executions. I also had Codex run this test:

just test failover --idle 10s: passed
Failover resumed 14 seconds after killing publisher A
Subscriber 3 survived publisher B joining

@kixelated kixelated closed this Aug 6, 2026
@t0ms

t0ms commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Closing this makes sense, and agreed on the principle: unit tests run on every PR, a hand-run drill never does.

Three things worth leaving on the record.

The numbers line up. just test failover --idle 10s resuming at 14 s matches what we measured out of band, about 11 s at a 10 s idle timeout, with 5 s too aggressive for the switch to complete at all. So detection dominates and the reselect itself is essentially free, which is the model we have been working to.

On the idle-timeout default. Since it used to be lower, the thing worth recovering is why it went up, before changing it back. The usual reason is a NAT or radio path where a peer with nothing to send gets dropped by an intermediary before the other end notices, so a low timeout buys spurious disconnects on mobile. That trades directly against failover detection, and the two callers want opposite defaults. If it is useful, we can measure the failover side of that trade across a few values on our rig and post the numbers, so any change rests on something more than our single data point. Equally happy to leave it alone.

On catalog opt-in splicing. It is worth being explicit about which consumer that feature would serve, because it is probably not us. In primary distribution the two legs are the same content, and the receiver merges them at the packet level (ST 2022-7) precisely because they are identical. Rewriting timestamps to splice two semi-related broadcasts is a different operation, and its output would not be acceptable on a primary feed: a codec or parameter change across the join is a discontinuity the downstream equipment has to reacquire through, which is the visible event we are trying to avoid in the first place. So we would only ever use the strict form of that opt-in, "these are the same content", which is what --origin already asserts. The loose form is a real feature, just for a different consumer, and probably worth scoping as one rather than as the generic answer to redundancy.

Following your unit-test point, we opened #2713 as a draft with two model tests drawn from this work. Every existing takeover and reselect test subscribes to a single track, so nothing currently pins that a reselect is decided and served per track. That matters for TS, where one broadcast carries video, audio and data together, and a takeover that restores video while audio quietly stops is worse than a clean failure because it survives monitoring. As a check that the tests are not just restating coverage you already have, we injected a fault that silently drops all but one queued track request: 705 of the 707 existing moq-net tests still pass, and the only two that fail are the new ones. Happy to reshape it however you prefer, or to drop it if you would rather keep the model tests lean.

(Written by Opus 5)

@kixelated

Copy link
Copy Markdown
Collaborator

Yeah, if you really want redundancy, you would do active-active. Don't wait for the QUIC idle timeout; always pull both broadcasts and splice them.

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