Skip to content

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

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

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.

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.

1 participant