Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Published API or wire breaks still land on dev; each quest's Plan says so.
- [quest check everywhere](/quest/m0/quest-check-everywhere.md) - `quest check` guards `main`, `dev`, and the line branches on push and PR, not only PRs into `main`
- [noq reassembly cap](/quest/m0/noq-reassembly-cap.md) - noq carries quinn's stream reassembly cap and the connection receive window is finite by default
- [qmux reset race](/quest/m0/qmux-reset-race.md) - qmux handles RESET_STREAM under one lock instead of panicking
- [Path hunting](/quest/m0/path-hunting.md) - a withdrawn path is retracted about once per relay instead of hunting stale alternatives, with a hold-down on route updates and no wire change
- [Shared fronts](/quest/m0/shared-fronts.md) - viewer sessions share a front, so fronts scale with peers, not viewers
- [Frame alloc budget](/quest/m0/frame-alloc-budget.md) - frame buffers pre-allocate within a per-session budget and otherwise grow with bytes received
- [Handshake deadline](/quest/m0/handshake-deadline.md) - an unfinished handshake or slow HTTP header times out
Expand Down
49 changes: 49 additions & 0 deletions quest/m0/path-hunting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# [M] A withdrawn path stops hunting

## Goal

When a broadcast ends, each relay in a lite-06 mesh retracts it about once,
instead of re-announcing stale alternatives. Today one withdrawal on live's
34-relay graph costs hundreds to over a thousand extra announce starts, and
on Kick dev an ended name drew 575 to 57,805 mesh announce starts (median
about 13,000) while live names drew one flood (moq-dev/moq.pro#2116). New
and removed routes still propagate at once, and no wire changes.

## Plan

Promoted from the cluster routing line to m0 on 2026-09-30: it is today's
partial-mesh hunting that #4399 left open, and it is hurting production.

Decided (2026-09-30), lite-06, no wire change:

- A route *update*, where a relay's advertised route for a path switches to a
different route, waits a short hold-down before it is re-advertised. A new
path (nothing advertised yet) and a removal (nothing left) go out at once.
- So when the best route is withdrawn and an alternative remains, the relay
retracts at once and re-announces the alternative only if it survives the
hold-down. Stale alternatives are withdrawn inside that window and never
spread. A genuine failover, where a link dies but the origin still
publishes, is delayed by the hold-down; pick the value from the repro, and
report the failover cost it adds.
- Check the interaction with #4399's withdrawal hiding and with
subscriptions already pinned to a route (fronts keep serving from a live
source; only advertisements are held).

Test first: land moq.pro's in-process repro upstream (real lite-06 sessions
over the mock transport on live's 34-relay graph, mocked time), asserting
about one retraction per relay for a single withdrawal from a non-hub
publisher, plus a failover test where a link dies and the path comes back
through another route after the hold-down. It must fail on `main` today.

Then moq.pro moves its pin and confirms about one retraction wave per ended
name on Kick dev after a deploy the user approves (moq.pro's own quest).

If no hold-down closes it, the fallback is a per-origin seqno on cluster
links in the wip lite version; report before building it.

Public API: none expected. Wire: none.

## Related

- [Cluster routing](/quest/m1/cluster-routing/README.md) - tiers shrink the core graph this hunts on
- [Cross-relay delivery under bursts](/quest/m1/cross-relay-bursts.md) - closed broadcasts announced for minutes, the same symptom
5 changes: 4 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ QUIC studies there on that rule.
## Required

- [Cluster idle timeout](/quest/m1/cluster-idle-timeout.md) - a relay notices a silent peer relay within seconds, not after the shared 30 s QUIC idle timeout
- [Cluster routing](/quest/m1/cluster-routing/README.md) - a relay learns the relay graph once and each announcement once, not once per neighbour, and redundant publishers share an epoch instead of `--hop`
- [moq-transport peers are plain clients](/quest/m1/ietf-cluster-off.md) - no moq-transport session negotiates the cluster extension, so none can splice a pool's members on a Hop ID label
- [Cluster routing](/quest/m1/cluster-routing/README.md) - edge and core tiers carry each broadcast into a region once over path vector, with the backbone hidden from end users
- [Remove `--hop`](/quest/m1/hop-removal.md) - on `dev`, redundant publishers share an explicit `@<epoch>`, and `--hop` and the publisher's Hop ID are gone
- [One route cost](/quest/m1/route-cost.md) - on `dev`, Warm and Cold collapse to one static route cost
- [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop`
- [Merge queue](/quest/m1/merge-queue.md) - the required checks run on `merge_group`, so a stale green check can no longer break main
- [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi and every wrapper configure and observe audio playout delay
Expand Down
187 changes: 62 additions & 125 deletions quest/m1/cluster-routing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,128 +2,66 @@

## Goal

Cut cluster gossip. A relay learns how to reach each other relay once, from
a topology shared across the cluster rather than repeated in every route,
and learns each announcement once rather than once per neighbour. Every relay
still holds the ledger of live announcements that ANNOUNCE_REQUEST and a cold
SUBSCRIBE need, and a broadcast under overlapping prefixes routes to one
origin deterministically. Redundancy an operator configures may deliver more
than one copy.

Non-goals: warm re-origination (a warm relay would be one more origin with a
cost, so leave room for it), and a permanently mixed-version cluster.
A cluster carries each broadcast into a region once and never exposes its
backbone to end users. Relays take an explicit edge or core role: edges
serve end users and spread paths over their region's cores, cores link to
the cores of other regions over configured links with static costs, and
routing between them stays path vector. A broadcast under overlapping
prefixes routes to one origin deterministically. Redundancy an operator
configures may deliver more than one copy.

Non-goals: edges acting as each other's intermediates (it makes edges a
bigger DDoS target), cache-aware route switching, and a permanently
mixed-version cluster.

## Plan

### Decisions

Settled in the 2026-09-30 `/quest-plan`:

- The goal stays wide. The design below is the current candidate, not a
decision; [Propagation](/quest/m1/cluster-routing/propagation.md) settles
how announcements move and writes the implementation children that follow.
- Topology is a cluster message kept apart from routes, built from configured
links only (`--cluster-connect`, the connect API, LAN mDNS). Gossip
discovery goes first, but only the topology child waits on it.
- Every relay holds a record for every live announcement, since
ANNOUNCE_REQUEST and a SUBSCRIBE with no prior announce both need one.
Scoping that knowledge by demand needs registries, which Propagation may
defer to m2.
- `--hop` goes. An epoch-qualified concrete path (`foo/@<uuidv7>`) is the
identity of a source: origins that announce the same one are
interchangeable, which is how a redundant pair is expressed. It is strictly
better than a Hop ID, which is per session, so a connection could not
publish several broadcasts with different identities. A path a claim
produces keeps Wildcard's per-origin identity, even once announced
concretely, since each worker's output is its own.
- The line lands on `dev`: deleting `--hop` and the publisher's Hop setup
parameter breaks a published CLI and wire. Wire changes go in the current
wip version (`moq-lite-07-wip` today, dropping `Hop Base` and `Hop Keep`
before they publish). If lite-07 is finalized first for the Wildcard
rollout, the remaining children move to the next wip version; finalizing
never waits on this line.
- A short idle timeout for cluster sessions is its own m1 quest,
[Cluster idle timeout](/quest/m1/cluster-idle-timeout.md), since it helps
today's path vector too.
- Reduced flooding ([RFC 9667](https://www.rfc-editor.org/rfc/rfc9667)) and
registries are m2 unless Propagation pulls registries in; Propagation writes
whichever it defers.
Settled in the 2026-09-30 wildcard audit (cache tiers), replacing the
link-state and existence-split design planned earlier that day:

- Two roles, edge and core. Where a region has one edge, that edge is also
its core. A region may have several cores, which do not link to each other.
An edge dials every core in its region over qmux on TLS (intra-region links
are not congested) and picks one per path by rendezvous hashing, so a
broadcast crosses into a region once. An edge never re-advertises one
core's routes to another, so it is never transit. Cores dial the cores of
the regions they link to, only those with lower names, so each pair has one
connection. Cores are hidden from end users, which is the DDoS shield. See
[Edge and core](/quest/m1/cluster-routing/roles.md).
- Core links are configured and static, and may skip PoPs when the RTT is
short: Seattle pulls from San Jose directly rather than via Oregon. Nothing
switches routes on cache state, so Warm and Cold collapse to one route cost
([One route cost](/quest/m1/route-cost.md)), and the wildcard line's route
upgrade quest is deleted.
- No link-state topology. With split-horizon edges and a sparse core graph,
path vector with hop lists stays loop-free, path hunting is confined to the
cores, liveness flooding (the simulator's dominant link-state cost) is never
Comment on lines +38 to +40

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Preserve a request-loop invariant across core-session loss

Loop-free advertised hop lists do not make per-hop request reselection loop-free. The allowed core graph can contain a cycle across three regions. After their common upstream D disconnects, A/B/C can still hold stale alternatives [P,D,B], [P,D,C], and [P,D,A]: A requests B, B excludes A and requests C, then C excludes B and requests A. Each choice passes the immediate-peer check. Lost sessions only drop their routes; they do not apply explicit-withdrawal hiding to every path through D (origin.rs:3404–3408). The front cache coalesces the repeated request, leaving a circular wait until routing converges.

Selection now drops the end-to-end visited-relay protection. Retain a request-loop guard or another demonstrated invariant, and include this cyclic-core loss case in the measurement before declaring hop lists sufficient. Tiering removes edge transit but does not remove core cycles.

(Written by OpenAI Codex)

paid, and failure detection sets the outage window either way. The parked
implementation is #4631. If stale paths remain on the tiered layout, the
fix is [Path hunting](/quest/m0/path-hunting.md), promoted to m0: a hold-down
on route updates, with no wire change.
- The line lands on `main`: its children are additive. The two breaking
changes left it for `dev` on their own:
[Remove `--hop`](/quest/m1/hop-removal.md) and
[One route cost](/quest/m1/route-cost.md).
- Cluster links are moq-lite only; moq-transport peers are plain clients
([moq-transport peers are plain clients](/quest/m1/ietf-cluster-off.md)).
- An epoch-qualified concrete path (`foo/@<uuidv7>`) is a source's identity:
origins that announce the same one are interchangeable, which is how a
redundant pair is expressed. A path a claim produces keeps Wildcard's
per-origin identity. See [Selection](/quest/m1/cluster-routing/selection.md).
- Between clusters, announcements stay path vector with cluster ids as hops,
in the last child.

### Why not path vector or Babel

Today every relay advertises its best route to every peer not already in the
hop chain, and pulls every peer's full table with an empty-prefix
ANNOUNCE_REQUEST. One publish costs about R·(d-1) announces for R relays of
mesh degree d, every relay learns every broadcast (`.stats` and `.internal`
included), and a link change rewrites every route crossing it. On moq.pro's
live fleet (26 PoPs, average degree about 5) each relay receives every event
about five times. Babel ([RFC 8966](https://www.rfc-editor.org/rfc/rfc8966))
shrinks the message and skips equal-cost reroutes, but it is still distance
vector: the same fan-out, the same global knowledge, and no loop freedom
when several sources claim a prefix (section 2.7), which pools and wildcards
make MoQ's common case.

moq.pro's routing simulator (`just rs sim`,
[moq.pro#2020](https://github.com/moq-dev/moq.pro/pull/2020)) measured worse
than that on live's relay graph (34 relays, mean degree 8.4): a publish costs
about 750 announces, and ending a path's only publisher explores stale
alternatives for about a second and 39k to 80k announces, with subscribes
looping meanwhile. Coalescing each writer for 10 ms cuts that by 7x and leaves
the loops. Hiding the routes through a peer that withdrew the path
([#4399](https://github.com/moq-dev/moq/pull/4399)) reduces that path hunting
but does not end it; see the findings below.

### Candidate design

The children own their parts: topology in
[Topology](/quest/m1/cluster-routing/topology.md), existence and registries
in [Propagation](/quest/m1/cluster-routing/propagation.md), origin choice in
[Selection](/quest/m1/cluster-routing/selection.md).

- Existence is split from reachability. An announcement carries the path, its
origin relay, and the origin's cost, and nothing about the path to it.
- An existence event carries the origin's seqno, scoped to its incarnation. A
relay applies an event only when it is newer than the last it applied for
that path and origin, and keeps an ended path's seqno, so a start delayed on
a stale tree or a failed-over registry cannot revive it. Babel keeps
feasibility past withdrawal for the same reason
([RFC 8966 section 3.7.3](https://www.rfc-editor.org/rfc/rfc8966#section-3.7.3)).
- A new incarnation is learned from existence, never inferred from liveness:
it announces a reset (its incarnation at seqno 0) that ends everything the
old one published, and a relay applies a reset and the records after it as
one batch. Incarnations are ordered, so a delayed reset from an older one is
dropped. Purging on a liveness report instead drops a restarted origin's
broadcasts until their re-announce lands, which the simulator showed as live
broadcasts reported offline.
- Relays flood per-link liveness among themselves with a per-link seqno scoped
to the relay's incarnation, batched for a short hold-down, with a digest
exchange on session up.
- A relay picks the origin with the lowest shortest-path distance plus origin
cost, ties broken by rendezvous hashing (HRW) of the requested path and the
origin id, and forwards along its shortest path. The longest covering
prefix still ranks first, per [Wildcard](/quest/m0/wildcard/README.md). The
first relay's choice rides the SUBSCRIBE, and transit relays forward toward
that origin by topology alone. SUBSCRIBE and FETCH carry a visited-relay
list as a loop safety net.
- Announcements to clients are on demand: a relay forwards only the union of
its clients' ANNOUNCE_REQUEST prefixes. `.stats` becomes ordinary demand.
- Registries are an optional, configured tier: moq-relay in a registry mode,
one or more per region. Registries form a small full mesh and flood
existence among themselves, so an event crosses an ocean once per remote
registry. A relay fails over to the next-nearest registry and reconciles;
with none reachable it freezes its view and alerts rather than falling back
to flooding.
- Without registries, existence floods along the shortest-path tree, one copy
per relay. A relay forwards an event only when it changes its view, and a
relay that gains a child in its tree pushes that origin's reset and records
to it.
- The cluster switches versions as a whole; older lite and IETF sessions stay
at its edges.
- Anything specific to moq.pro's deployment (generating peer lists and roles
from its inventory, its simulator) is planned in moq.pro, not here.

### Simulator findings

Measured on the earlier flat mesh; they motivated the link-state design the
tiers decision replaced, and still bound what path vector costs.

The report is moq.pro's `just rs sim` (every scenario on live's graph) and
`just rs sim sweep` (synthetic regional graphs of 34, 340, and 1020 relays).
It carries messages and bytes by kind, per relay and cross-region,
Expand All @@ -147,8 +85,8 @@ registry.
relay loss is noticed after the 30 s QUIC idle timeout in every candidate,
and subscribes through it go nowhere until then.
- The simulator saw no loop while views agreed, and HRW split an equal-cost
pool 63/49 where today's hash of the announced prefix sends all of it to one
sibling. On live's graph no disagreement looped across 20 seeds of link,
pool 63/49 where a hash of the announced prefix sent all of it to one
sibling (Wildcard has since keyed the tie on the requested path). On live's graph no disagreement looped across 20 seeds of link,
cost, and relay churn.
- One registry per region and two cost about the same; two halves the
busiest registry's load. A registration sent on a dead registry session
Expand All @@ -169,26 +107,25 @@ registry.

Once every child has landed:

- An end-to-end test: a multi-relay cluster in `rs/moq-relay/tests` where a
publish, an end, a relay restart, and a redundant-pair failover each reach
a subscriber on a far relay, with time mocked.
- Rewrite `doc/bin/relay/cluster.md` into the operator's view (topology,
link costs, idle timeout, redundant pairs), and add a routing page under
`doc/concept`.
- An end-to-end test: a two-region cluster in `rs/moq-relay/tests` where a
publish, an end, a core loss, and a redundant-pair failover each reach a
subscriber on a far edge, with time mocked.
- Rewrite `doc/bin/relay/cluster.md` into the operator's view (edge and core
layout, dial rules, TLS edge links, link costs, idle timeout, redundant
pairs), and add a routing page under `doc/concept`.

## Required

- [Topology](/quest/m1/cluster-routing/topology.md) - relays learn the relay graph once from a cluster message, apart from routes
- [Propagation](/quest/m1/cluster-routing/propagation.md) - decides how each announcement reaches every relay once, and writes the implementation children
- [Edge and core](/quest/m1/cluster-routing/roles.md) - relays take an explicit edge or core role; edges spread paths over their region's cores and are never transit
- [Selection](/quest/m1/cluster-routing/selection.md) - a broadcast under overlapping prefixes routes to one origin deterministically, and same-epoch origins are one source
- [Remove `--hop`](/quest/m1/cluster-routing/hop-removal.md) - redundant publishers share an explicit `@<epoch>`, and `--hop` and the publisher's Hop ID are gone
- [Between clusters](/quest/m1/cluster-routing/inter-cluster.md) - announcements crossing a cluster boundary stay path vector with cluster ids as hops

## Related

- [Wildcard](/quest/m0/wildcard/README.md) - the longest-prefix rule, pool spread, and reply identity Selection builds on
- [Cluster idle timeout](/quest/m1/cluster-idle-timeout.md) - failure detection sets every outage window, today and after this line
- [Remove `--hop`](/quest/m1/hop-removal.md) - on `dev`: redundant publishers share an explicit `@<epoch>`
- [One route cost](/quest/m1/route-cost.md) - on `dev`: Warm and Cold collapse to one static cost
- [Same-hop importers](/quest/m1/hop-aligned-import.md) - the importer half of a redundant pair; `--hop` removal re-keys it to a shared epoch
- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - a redundant pair shares one epoch
- [Cross-relay delivery under bursts](/quest/m1/cross-relay-bursts.md) - its #4349 report also shows closed broadcasts announced for up to 229 s and flapping between Retracted and Announced across nodes, evidence for per-incarnation seqnos
- [Cross-relay delivery under bursts](/quest/m1/cross-relay-bursts.md) - its #4349 report shows closed broadcasts announced for up to 229 s, evidence for per-origin seqnos
- [Routing cost domains](/quest/m3/routing-cost-domains.md) - cost across the cluster boundaries this keeps path vector
Loading
Loading