Skip to content

fix(net): hold back a lite-06 copy of a lite-07 claim until its route goes - #8212

Closed
kixelated wants to merge 4 commits into
mainfrom
quest/m2/cluster-routing/routes/lite06-failover
Closed

kixelated wants to merge 4 commits into
mainfrom
quest/m2/cluster-routing/routes/lite06-failover

Conversation

@kixelated

@kixelated kixelated commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Claims quest/m2/cluster-routing/routes/lite06-failover.md and deletes it.

Problem

The quest said a relay declines a moq-lite-06 route for an identity it already knows over moq-lite-07, and never reconsiders it. On main that is not what happens. moq-dev/moq#8023's final version guards only the relay's own routing identity, so it declines nothing else. Failover already worked on main (reproduced: D restarts onto the lite-06 route).

The gap is the other half of the quest's plan, which main never did: recognizing known identities. Without it, a relay that hears P's claim over lite-07 and again as B's translated lite-06 chain [hop(P), B] files the copy as one of its own claims and advertises it to its lite-07 peers. Once P withdraws, every node, P included, restarts onto that stale copy before it retracts. In a triangle P -07- B -06- D -07- P, main shows [Restart, End] at P, B and D.

Approach

  • The route node publishes what it routes to. route::node::Known maps the lite-06 hop (route::node::hop) of each other identity to what its usable feasible routes through unscoped neighbors can carry (Router::carried: the prefix asked of each neighbor), plus an epoch. State::flush updates it incrementally from Router::changed.
  • The origin owns that channel from construction (OriginState::known) and hands it to the node when the first lite-07 session creates it. So a lite-06 session can watch it before any node exists.
  • lite-06 (and older) ingress holds back a chain naming one where its route carries that path. Subscriber::shadowed joins the announce path to the session origin's root, the frame the route layer keys claims in. Announced::offer checks it first and keeps the route as Held::Shadowed(RouteInfo). A lite-07 route that carries only a/ leaves a lite-06 copy at b/x alone. Withdrawing a claim leaves the route in place, so a withdrawal is still told apart from a lost route. An ANNOUNCE_UPDATE that makes an attached route's chain name a known identity goes through offer too.
  • A change re-evaluates every held route. Each non-routed announce stream polls the epoch. Announced::reconsider holds back attached routes that now name a known identity and offers held-back ones that no longer do. The re-offer goes through the session limit, so a held-back route outside it becomes Withheld, and drain applies after a GOAWAY.
  • The own-identity reflection stays a permanent decline in Subscriber::chain.

Choices made without asking

  • Symmetric, not sticky. The lite-06 copy is held back whenever the lite-07 route is usable, including after a failover once that route comes back. Readers then restart back onto the publisher's own instance, which the route layer can carry through later failovers. A sticky copy would avoid that second restart but would keep the duplicate claim, and with it the stale revival this fixes. The result would also depend on which path arrived first.
  • Failover is End then Start, not Restart. When the lite-07 session is lost, its routes leave the origin with it, before its node stream disconnects and the route layer learns the identity is unreachable. So the held-back copy is announced afresh. Getting a Restart would mean either keeping the copy in the origin as a standby, which brings back the local revival on withdrawal because no one can yet tell a withdrawal from a lost route, or holding a dead session's routes. Either way readers re-request, since lite-06 mints its own instance. doc/bin/relay/cluster.md says "announces it afresh".
  • Scoped lite-07 routes hold nothing back. A scoped session serves only what its permit's limit admits, and its regions only bound that limit (wildcard limits such as live/*/public/**). Its identities are qualified anyway, so a bridge's translated hop rarely matches. Counting only unscoped routes, whose limit admits everything, settles every such case, at the cost of main's behaviour (a duplicate own claim) for copies of a scoped peer's claims. Mirroring the permit in the node would need the limit patterns there for a rare case.
  • "Known" means a usable feasible route. A held (ROUTE_HOLD) or infeasible route does not count, so the lite-06 path also covers a route that is reconverging. The cost is a possible extra restart while Babel recovers.
  • A whole-chain check, like the own-identity reflection. A translated chain only ever starts with an identity hop, so this matches the first hop in practice.

Impact

  • Public API: none. Crate-private: route::Node::random takes the Known channel, Node::new is now test-only, and Router::carried is added.
  • Wire: none.
  • Behaviour: in a mixed-version cluster, a relay no longer re-files a lite-07 node's claim it hears back over lite-06 while it still routes to that node. Withdrawal retracts once. Failover onto the lite-06 path is announced as a fresh start.

Caused by

  • moq-dev/moq#8023: lite-06 routes enter the route layer as the relay's own claims, and its guard covered only the relay's own identity, so a translated copy of another node's claim became a duplicate own claim.

Overlap with #8063

moq-dev/moq#8063 does not do this failover. It touches route/node.rs (Claim::continues, the own lineage) and Announced near offer, but not State::flush, Held, or the announce loop's poll. Expect small textual conflicts there and in origin.rs around route_node, whichever lands second.

Validation

  • mixed_version_triangle_withdraw_retracts_once (new): P, B and D each see only End on withdrawal. It fails on main with [Restart, End].
  • mixed_version_failover_takes_up_the_lite06_route (new): cutting the P-D link announces the lite-06 copy afresh and a request succeeds. Re-linking restarts D back onto P's lite-07 instance, and a withdrawal then ends cleanly. The recovery step fails on main, which never holds the copy back.
  • route::node::tests::known_names_a_routed_identity_where_its_route_carries (new): a stream asking for live names P at live/x and live/y but not other/x. A withdrawal keeps the name, and dropping the stream clears it and bumps the epoch.
  • route::node::tests::known_leaves_out_a_scoped_route (new): a scoped route names nothing, even by the qualified identity's own hop. It fails without the scope filter.
  • mixed_version_ring_withdraw_retracts and the rest of mesh_withdraw still pass.
  • test/drill/mutations/subscriber-leaks-broadcasts.patch is retargeted: its context included the PrefixRun initializer, which gains known_epoch.
  • just check passes on this head (12746 tests).

Suggested follow-ups

  • Plan it: a mixed-version ring circles a withdrawn claim forever. Ring P -07- B -06- D -07- E -06- F -07- B, publisher at P: after P withdraws, the claim never settles, and the test hangs at one simulated instant. Each lite-07 crossing restarts the translated chain at the claiming node's identity hop, so D -> E -> F -> B -> D re-files it as own claims with no hop ever repeating. This PR breaks such a loop only when the relays on either side of a lite-06 link also know each other over lite-07. The same shape with old lite-06-only relays between lite-07 islands (N2 -07- N3 -06- O -06- N4 -07- N5 -06- O' -06- N2) loops too. It predates this PR (from feat(net)!: lite-07 routes through the route layer #8023) and is a correctness bug for mixed rollouts, so it is worth a quest. A likely direction is carrying a learned claim's hop history (or a count bound) across lite-07.

Waiting

  • Maintainer: pick the hold-back's coverage rule. Review round 3 (a design checkpoint) found a third case of the same class. A lite-07 route at D can carry less than D's own view of it says, so D holds back a lite-06 copy that no lite-07 route serves, and the copy is unreachable. Rounds 1 and 2 covered the prefix D asked for and a scoped neighbor's permit, both fixed. Round 3: a scoped identity forwarded by an unscoped relay keeps the upstream permit's narrowing. The same holds for any narrower prefix a relay asked of its own upstream. Options:
    1. Recommended: hold back by the claims D actually received. Hold back the lite-06 copy at X naming P's hop while P is routed and P's claim at X arrived over lite-07. Keep it held after that claim is withdrawn, as long as P stays routed, and release it once P's route is lost. This is exact by construction, since it uses what lite-07 delivered, so no scope or prefix case can recur. Cost: Known mirrors the routed identities' shown paths, and every claim change wakes the lite-06 streams. That needs an incremental changelog to stay cheap at scale.
    2. Exclude qualified identities too (the reviewer's suggestion), and accept that a relay asking its upstream a narrower prefix can still over-hold. Smallest diff, but it leaves a known hole.
    3. Drop the hold-back and delete the quest: main already fails over (as a restart). The cost is the stale revival on withdrawal at mixed-version boundaries ([Restart, End] everywhere in the triangle).
      Check: the maintainer's pick is recorded on this PR; then /quest-iterate implements it and starts quest review --new.

Models

Size: M
Implementer: Claude Code, claude-opus-5-5, high effort

Review

Rewritten by quest review after each round.

  • Reviewer: Codex, gpt-6.1-sol, xhigh effort
  • Session: 01a1b8bc-21c7-75f1-ba0a-4b4d7531fa2d, round 3
  • Head: 16b5408, named in full once a round reports no findings
  • Base: origin/main at f8d30a1
  • Score: 7/10, The direct scoped-neighbor case is fixed, but forwarding through an unscoped relay can still make valid legacy broadcasts unreachable.
  • Findings, open until the next round settles them:
    • Medium, rs/moq-net/src/route/mod.rs:898: Exclude scoped identities after relay forwarding
  • The last round's findings, at 1f931aa:
    • Open: medium, rs/moq-net/src/route/mod.rs:289: Include full permit limits in shadowing coverage; The immediate scoped-session case is fixed, but rs/moq-net/src/route/mod.rs:898 still accepts scoped identities forwarded by unscoped relays, recreating suppression outside the upstream permit.

kixelated and others added 2 commits November 7, 2026 14:44
… goes

A relay hearing a claim both over moq-lite-07 and as a translated moq-lite-06
chain filed the copy as one of its own claims, so after the publisher withdrew,
every node restarted onto that copy before it retracted. The route node now
publishes the lite-06 hops of the identities it has a usable route to. A
lite-06 announce whose chain names one is held back, and taken up again once
that route is lost, so the lite-06 path still takes over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title A declined lite-06 route is reconsidered when the lite-07 path fails fix(net): hold back a lite-06 copy of a lite-07 claim until its route goes Nov 8, 2026
kixelated and others added 2 commits November 7, 2026 18:10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Closing without merging. Maintainer decision, 2026-11-07 (/quest-spawn, design checkpoint after review round 3): ✅ drop the hold-back and delete the quest. Main already fails a lite-06 route over when the lite-07 path dies, so the quest's premise does not hold. What remains is a transient Restart, End on withdrawal at mixed-version boundaries, and each review round found another case where holding back the lite-06 copy over-holds. Not chosen: holding back on the claims received over lite-07 (needs an incremental changelog), or excluding qualified identities.

The ring-loop bug found here (two lite-06 links between lite-07 islands circle a withdrawn claim forever, on main since #8023) is being planned as its own quest.

(Written by Claude Opus 5.5)

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