feat(net): route by cumulative cost on lite-06 announcements - #2424
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
b9f714a to
d52e5d0
Compare
WalkthroughAdds per-link routing costs to MoQ-lite SETUP and ANNOUNCE messages with version-gated encoding and legacy defaults. Client and relay configuration now carry costs into subscriber route construction. Publishers track and recheck advertised costs, while broadcast activity affects outgoing cost. Route selection gains carrying-aware deterministic handover logic. Documentation and tests cover protocol compatibility, cost accumulation, saturation, URL parsing, and route updates. 🚥 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.
🧹 Nitpick comments (1)
rs/moq-net/src/lite/publisher.rs (1)
434-446: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winOptimize cost drift detection to avoid O(N) allocations.
Currently, the periodic cost recheck calls
entry.consumer.route()for every watched broadcast, which acquires anRwLockread lock and clones theVec-backed hop chain (OriginList).Since the target cost is strictly
0when actively carrying, and always equals the unchangingroute.costwhen idle (because any route change would have eagerly triggeredpoll_route_changedinstead of reaching here), you can use the currently recordedsent.costto safely skip fetching the route in the vast majority of cases.⚡ Proposed fast-path optimization
// The advertised cost drifts without a route change when // liveness flips; reuse the route path to re-send it. if version.has_route_cost() && let Some(sent) = &entry.sent { + // Fast path: avoid allocating and cloning the route's hop chain + // on every tick if the liveness-based cost hasn't changed. + let active = entry.consumer.is_active(); + if active == (sent.cost == lite::RouteCost(0)) { + continue; + } + let route = entry.consumer.route(); if Self::outgoing_cost(version, &entry.consumer, &route) != sent.cost { return Poll::Ready(Ok(Op::Route(suffix.clone(), Ok(route)))); } }🤖 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/lite/publisher.rs` around lines 434 - 446, Optimize the cost-drift check in the periodic recheck block by using the recorded sent.cost as a fast path: when it already represents the expected active-carrying cost of 0 or the unchanged idle route cost, skip entry.consumer.route() and its allocation. Only fetch the route and recompute outgoing_cost when sent.cost indicates a possible liveness-driven drift, while preserving the existing Route response and route-change handling.
🤖 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/lite/publisher.rs`:
- Around line 434-446: Optimize the cost-drift check in the periodic recheck
block by using the recorded sent.cost as a fast path: when it already represents
the expected active-carrying cost of 0 or the unchanged idle route cost, skip
entry.consumer.route() and its allocation. Only fetch the route and recompute
outgoing_cost when sent.cost indicates a possible liveness-driven drift, while
preserving the existing Route response and route-change handling.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 62339f02-de36-4911-aacc-e2d4261b9e89
📒 Files selected for processing (21)
doc/bin/relay/cluster.mddrafts/draft-lcurley-moq-lite.mdjs/net/src/lite/announce.test.tsjs/net/src/lite/announce.tsjs/net/src/lite/version.tsrs/kio/src/producer.rsrs/moq-native/src/client.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/subscriber.rsrs/moq-net/src/lite/version.rsrs/moq-net/src/model/broadcast.rsrs/moq-net/src/model/origin.rsrs/moq-net/src/model/requests.rsrs/moq-net/src/model/resume.rsrs/moq-net/src/model/weak_cache.rsrs/moq-net/src/server.rsrs/moq-relay/src/cluster.rs
💤 Files with no reviewable changes (1)
- rs/moq-net/src/model/requests.rs
There was a problem hiding this comment.
🧹 Nitpick comments (1)
rs/moq-net/src/lite/publisher.rs (1)
398-745: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd regression coverage for the idle/linger repricing path.
This module has unrelatedrecv_nexttests, but nothing that exercisesOp::Idle,Op::Linger, demand dropping to zero, or demand returning beforeCOST_LINGERexpires.🤖 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/lite/publisher.rs` around lines 398 - 745, Add regression tests covering the publisher announce loop’s idle/linger behavior around Op::Idle, Op::Linger, and COST_LINGER: verify a zero-demand transition starts lingering without immediately repricing, demand returning before expiry cancels the pending restore, and expiry reprices the route back to its cold cost. Reuse the module’s existing publisher/recv_next test setup and assert the emitted route updates and costs.Source: Coding guidelines
🤖 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/lite/publisher.rs`:
- Around line 398-745: Add regression tests covering the publisher announce
loop’s idle/linger behavior around Op::Idle, Op::Linger, and COST_LINGER: verify
a zero-demand transition starts lingering without immediately repricing, demand
returning before expiry cancels the pending restore, and expiry reprices the
route back to its cold cost. Reuse the module’s existing publisher/recv_next
test setup and assert the emitted route updates and costs.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b87dcdcb-b2f4-4950-b62a-e6a38d9d6809
📒 Files selected for processing (7)
rs/kio/src/weak.rsrs/moq-net/src/lite/publisher.rsrs/moq-net/src/model/broadcast.rsrs/moq-net/src/model/origin.rsrs/moq-net/src/model/resume.rsrs/moq-net/src/model/track.rsrs/moq-net/src/model/weak_cache.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- rs/moq-net/src/model/resume.rs
- rs/moq-net/src/model/origin.rs
Routing today ranks candidate routes by hop count, which is blind to what a link costs and to which candidate already carries the media. Two relays in one datacenter each pull the same broadcast over a metered backbone, because the via-sibling route is +1 hop and shortest-hop routing never picks it. Make Route.cost the marginal cost of pulling the broadcast via that route, and put it on the wire: - The publisher seeds its production cost: zero for a live publish, large for a standby (e.g. a cold transcoder pool, where the existing hash tie-break then elects the same standby on every node). Each link adds its configured price as the announcement crosses it (SETUP link cost parameter 0x4, dialer declares, both ends charge; default 1 reproduces hop counting). - A node actively carrying the broadcast re-announces zero instead of the accumulated value: its ingress is already paid for, so peers pull the copy that exists rather than open a second one. This one rule covers both relay cache dedup and standby-transcoder activation, with no application code. Activity means live demand: a subscribed spliced track on a relay front (via the new kio consumer-liveness check), or a live track/request on an ordinary broadcast. - Going active re-announces immediately (minting the track wakes the announce loop, which now re-checks the advertised cost on every wake); going idle decays on a 5s tick, which doubles as hysteresis against viewer churn. - Two relays that pulled the same broadcast independently would each see the other's zero-cost route and swap sources simultaneously, leaving no source at all. FrontState::reselect now gates that switch on a build-stable FNV key of (broadcast, peer origin) vs (broadcast, self origin), so exactly one side re-parents; the reflected-announce check then removes the temptation. Chains stay bounded by the existing hop tie-break at equal cost. - route_order keeps its exact dev shape (announce, cost, hops, hash); pre-06 peers carry no cost and rank exactly as before. The relay prices links via a ?link_cost=N query param on cluster peer URLs (consumed locally, rides SETUP). js/net mirrors the wire change, and draft-lcurley-moq-lite documents the Route Cost field, the Link Cost parameter, and the tie-break guidance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two refinements to the route-cost model: - The handover key gate is now scoped to the one configuration that is actually hazardous: a cheaper route announced by a relay that is itself actively carrying the broadcast (advertised zero from a chain of two or more hops; a single-hop chain is the original publisher, which can never adopt a route to its own broadcast). Every other cheaper route, e.g. a forwarder path or an upstream that repriced itself down, is taken immediately via the usual group-boundary splice. The subscriber records the peer's pre-charge cost on the route (crate-private `advertised`) so the origin can recognize a warm peer. - The link price is just "cost" everywhere: `?cost=` on cluster peer URLs, `Client::with_cost` (moq-net and moq-native), the SETUP `Cost` parameter, and `lite::DEFAULT_COST`. Also added the lite-06 changelog entries the drafts convention requires, and dropped a public doc link into the private `lite` module that failed `cargo doc -D warnings`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace is_active() with broadcast::Demand, the broadcast-level sibling of track::Demand: a watch-only weak handle from Producer::demand() with is_used() plus used()/unused() (and poll_ variants) that resolve on the edge. The announce loop now waits for exactly the transition it cares about, watching unused while advertising zero and used while advertising cold, so the periodic cost recheck tick is gone. The consumer counts live on the per-track channels and their flips don't write the broadcast state, so Demand parks the waiter on every channel feeding the answer (spliced logical tracks, or an ordinary broadcast's tracks via their weak handles) alongside the state watcher. Decay keeps a deliberate 5s linger: demand draining starts a deadline instead of re-pricing immediately, demand returning within it cancels the restore, and only an expired deadline restores the cold cost, so viewer churn doesn't flap routing across the mesh. Activation still re-prices immediately. Demand on an ordinary broadcast now means subscriber interest (a pending request or a consumed track) rather than a live track producer: a publisher producing into the void is not a warm copy worth routing to, and the consumer count is what the edges can actually watch. Spliced broadcasts are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ccd90e9 to
1b19c84
Compare
Pull request was closed
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The announce loop in
lite::Publisherhas grown quite complex with the added COST_LINGER and demand polling logic; consider extracting the demand/linger handling into a helper or two to keep the main loop’s control flow easier to follow and reason about. - In
BroadcastState::register_demandandDemand::poll_demand, demand registration currently walks all tracks on every poll; if broadcasts are expected to have many tracks, it may be worth noting or revisiting this for potential per-broadcast or per-track coalescing to avoid O(n) work on each wake. - The handover key logic in
FrontState::handover_allowedrecalculates the FNV hash overself.pathand the last hop on each call; if this ends up on a hot path, caching the self-origin key per broadcast (and reusing it in both tests and runtime code) could reduce repeated hashing.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The announce loop in `lite::Publisher` has grown quite complex with the added COST_LINGER and demand polling logic; consider extracting the demand/linger handling into a helper or two to keep the main loop’s control flow easier to follow and reason about.
- In `BroadcastState::register_demand` and `Demand::poll_demand`, demand registration currently walks all tracks on every poll; if broadcasts are expected to have many tracks, it may be worth noting or revisiting this for potential per-broadcast or per-track coalescing to avoid O(n) work on each wake.
- The handover key logic in `FrontState::handover_allowed` recalculates the FNV hash over `self.path` and the last hop on each call; if this ends up on a hot path, caching the self-origin key per broadcast (and reusing it in both tests and runtime code) could reduce repeated hashing.
## Individual Comments
### Comment 1
<location path="rs/moq-net/src/lite/publisher.rs" line_range="474" />
<code_context>
+ match entry.idle_at {
</code_context>
<issue_to_address>
**issue (bug_risk):** The cost restore is delayed for roughly twice COST_LINGER due to how `idle_at` and `fired` are combined.
Today `idle_at` is set to `Instant::now()` when demand drains, the loop computes `deadline = idle_at + COST_LINGER`, and `linger` sleeps until that deadline. When the sleep completes, `fired` is set to `Instant::now()` (≈ `deadline`). The restore guard is `Some(at) if fired.is_some_and(|now| now >= at + COST_LINGER)`, which means we actually wait until `now >= idle_at + 2 * COST_LINGER`. If the intended delay is a single `COST_LINGER`, the comparison should be against `at` (the deadline) instead of `at + COST_LINGER`, or `idle_at` should be initialized to the deadline so the extra `+ COST_LINGER` isn’t applied twice.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| // Demand coming back within the linger cancels the | ||
| // restore; fall through to re-arm the unused watch. | ||
| Some(_) if entry.demand.is_used() => entry.idle_at = None, | ||
| // The linger expired: re-price via the route path. |
There was a problem hiding this comment.
issue (bug_risk): The cost restore is delayed for roughly twice COST_LINGER due to how idle_at and fired are combined.
Today idle_at is set to Instant::now() when demand drains, the loop computes deadline = idle_at + COST_LINGER, and linger sleeps until that deadline. When the sleep completes, fired is set to Instant::now() (≈ deadline). The restore guard is Some(at) if fired.is_some_and(|now| now >= at + COST_LINGER), which means we actually wait until now >= idle_at + 2 * COST_LINGER. If the intended delay is a single COST_LINGER, the comparison should be against at (the deadline) instead of at + COST_LINGER, or idle_at should be initialized to the deadline so the extra + COST_LINGER isn’t applied twice.
Routing today ranks candidate routes by hop count, which is blind to what a link costs and to which candidate already carries the media. Two relays in one datacenter each pull the same broadcast over a metered backbone, because the via-sibling route is +1 hop and shortest-hop routing never picks it. The same blindness applies to expensive work: a transcoder pool has no way to say "one of us is already transcoding this, route to it" versus "any of us could, pick one".
The cost model
Route.costbecomes the cumulative cost of the transfers (and work) a subscription via that route would newly cause, and rides lite-06 announcements as a single varint:Costparameter, id 0x4; the dialing side declares it and both ends charge the same amount; unpriced links cost 1, which reproduces hop counting exactly).route_orderkeeps its exact prior shape (!announce, cost, hops.len(), hash). Pre-lite-06 peers carry no cost and rank exactly as before; equal-cost warm copies resolve by hop length, which also bounds same-datacenter chains to one hop."Actively carrying" is exposed as
broadcast::Demand, the broadcast-level sibling oftrack::Demand: a watch-only weak handle fromProducer::demand()withis_used()plusused()/unused()edges (andpoll_*variants). Demand means subscriber interest: a subscribed spliced track on a relay front, or a pending request / consumed track on an ordinary broadcast (a publisher producing into the void is not a warm copy worth routing to). The consumer counts live on per-track channels whose flips never write the broadcast state, soDemandparks the waiter across every channel feeding the answer.The announce loop waits on exactly the transition it cares about, watching
unusedwhile advertising zero andusedwhile advertising cold, with no polling tick. Activation re-prices immediately; decay keeps a deliberate 5s linger (demand returning within the window cancels the restore), so viewer churn does not flap routing across the mesh.The simultaneous-activation race
Two relays that independently pulled the same broadcast both advertise zero, each sees the other as cheaper than its own source, and re-parenting onto each other simultaneously leaves the broadcast with no upstream at all (the reflected-announce check then breaks the cycle, both revert, and the attraction repeats: a stall/resume oscillation at announce-RTT cadence).
The fix is a deterministic tie-break scoped to exactly that hazard. While a front is carrying, a strictly cheaper route whose announcing relay is itself carrying (it advertised zero from a chain of two or more hops; a single-hop chain is the original publisher, which can never adopt a route to its own broadcast) only displaces an announced incumbent if the peer's build-stable FNV key for the broadcast is below ours. Both sides compute the same pair, exactly one re-parents (seamlessly, at a group boundary via the existing splice), and the loser's restarted announcement carries the winner in its chain, so the reflected-announce check removes the residual attraction. Every other cheaper route, e.g. a forwarder path or an upstream that repriced itself down, is taken immediately. The pre-charge cost needed to recognize a warm peer is kept on the route as a crate-private
advertisedfield.OriginListremains the authority on loop freedom; the key only prevents the transient double-switch.Config
Cluster peer URLs price their links with a
?cost=Nquery param, composing with static lists, gossip, andconnect_apifeeds:The param is consumed locally (the value rides SETUP, never the URL); a garbage value is a startup error rather than a silent default.
moq_net::Client::with_cost/moq_native::Client::with_costare the API surface.Cross-package sync
js/net: mirrors the wire change (hasRouteCost,coston ANNOUNCE_START/RESTART) with round-trip tests. The browser client does not charge links.drafts/draft-lcurley-moq-lite.md: Route Cost fields on ANNOUNCE_START/RESTART, the SETUP Cost parameter, the tie-break guidance, and changelog entries. Validated withjust drafts check.doc/bin/relay/cluster.md: new Link costs section.just test smoke-full; the wire change is gated behind the opt-inmoq-lite-06-wipversion, which the smoke matrix does not negotiate.Notes for review
devper the wire-change rule: the feature ridesmoq-lite-06-wip(opt-in, still WIP), extending ANNOUNCE_START/RESTART and adding a SETUP parameter. All Rust/JS API changes are additive.mainbase ontodev: the one conflict wasdetach_source, where dev'sgracefulteardown gate and this PR'sreselect(carrying)touch the same lines; the resolution keeps both. Everything else applied clean and the full suite passes on the dev base.base+transitsplit from that prototype collapsed into the single cumulative cost above (lexicographic policy-vs-distance turned out unnecessary once the cost is denominated in real units: prices sum).(written by Fable 5)