Skip to content

feat(moq-net): downstream viewer count on subscriptions (lite-05)#1672

Open
kixelated wants to merge 3 commits into
devfrom
feat/downstream-viewer-count
Open

feat(moq-net): downstream viewer count on subscriptions (lite-05)#1672
kixelated wants to merge 3 commits into
devfrom
feat/downstream-viewer-count

Conversation

@kixelated

@kixelated kixelated commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Carry a per-subscription viewer count that telescopes up the relay fan-out tree, so a publisher can learn its total downstream audience across any number of hops.

The count rides on Subscription as a downstream weight. A relay dedups N downstream subscribers into one upstream subscription, so the producer's aggregate sums every subscriber's weight and the relay forwards that sum upstream. A leaf reports 1; a relay reports its summed total. Viewer count is just a reduction up the existing subscription tree, so it telescopes for free.

Read the total on the publisher side with producer.subscription().map(|s| s.downstream).

Wire format (lite-05+ only)

Gated on a new Version::has_viewer_count() (lite-05-wip+). SUBSCRIBE and SUBSCRIBE_UPDATE carry downstream - 1:

  • The subscriber itself is the implicit 1, so a leaf encodes 0 (nothing downstream of it); decode adds the 1 back. This is what makes the field name "downstream" accurate.
  • A paused relay (0 viewers) saturates to wire 0 and reads back as 1 upstream — a held subscription can't represent fewer viewers than itself.
  • Older drafts omit the field entirely and every subscription weighs 1 (no telescoping, but each hop counts its direct subscribers). No interop break: lite-05-wip isn't ALPN-advertised and JS doesn't negotiate it yet.

Throttle (anti-flap)

The relay spaces upstream control messages for a subscription by at least 1s (SUBSCRIBE_THROTTLE). After sending a SUBSCRIBE/SUBSCRIBE_UPDATE it holds further subscription changes until the window elapses, then forwards the latest aggregate. The serve loop suppresses subscription polling during the window and re-polls when its timer fires.

Because the aggregate is level-triggered, a change that reverts within the window (a viewer joins and leaves, or leaves and returns) produces no upstream message at all. handle_subscription now reports whether it actually sent, so the window only arms on a real send.

The aggregation refactor (the subtle part)

The existing combine fold doubled as wakeup-registration and close-pruning: a subscriber returning Poll::Pending (its prefs already subsumed) was how its channel waiter got registered and how dropped consumers got pruned. Summing makes the merge always "change" the result, which silently broke both.

So registration and the value merge are now decoupled: each live subscriber is polled with a Pending closure purely to register the waiter and detect closure, then its value is read and merged separately (Subscription::merge). Bonus: this also fixes a latent snapshot over-count when a unique-preference subscriber dropped.

Tests

  • Wire (lite/subscribe.rs): round-trip across lite-05/lite-04, leaf → wire 0, paused 0 → reads back 1.
  • Model (model/subscription.rs, model/track.rs): sum across subscribers, prune-on-drop, default 1.
  • End-to-end through a real relay (moq-relay/tests/smoke.rs): stands up an actual moq-relay (Cluster + Connection) over WebTransport pinned to lite-05-wip.
    • relay_telescopes_downstream_viewer_count: two subscribers telescope to a single count of 2 at the publisher (not per-hop), then back to 1 on disconnect.
    • relay_throttles_subscribe_updates: a second viewer's bump is held behind the first's throttle window, so the publisher can't observe it near-instantly.

All 376 moq-net lib tests + the relay smoke suite pass (stable across repeated runs); clean clippy/fmt via the pinned nix toolchain.

Test plan

  • cargo test -p moq-net --lib
  • cargo test -p moq-relay --test smoke (telescoping + throttle, stable across repeated runs)
  • cargo clippy + cargo fmt via nix
  • workspace builds (hang, libmoq, moq-relay, moq-cli, moq-ffi)

Follow-ups (not in this PR)

  • Cross-package sync: js/net wire + Subscription, and doc/concept. No wire breakage today (lite-05-wip-gated), so deferred.

Branch

Targets dev per the branch-targeting policy (lite wire-protocol change under rs/moq-net).

(Written by Claude)

kixelated and others added 2 commits June 9, 2026 14:44
Carry a per-subscription viewer count that telescopes up the relay
fan-out tree, so a publisher can learn its total downstream audience
across any number of hops.

The count rides on `Subscription` as a `downstream` weight. A relay
dedups N downstream subscribers into one upstream subscription, so the
producer's aggregate sums every subscriber's weight and the relay
forwards that sum upstream. A leaf reports 1; a relay reports its
summed total. This telescopes for free: viewer count is a reduction up
the existing subscription tree.

Wire (lite-05+ only, gated on `has_viewer_count()`): SUBSCRIBE and
SUBSCRIBE_UPDATE carry `downstream - 1`. The subscriber itself is the
implicit 1, so a leaf encodes 0 (nothing downstream of it) and decode
adds the 1 back. A paused relay (0 viewers) saturates to wire 0 and
reads back as 1, since a held subscription can't represent fewer than
itself. Older drafts omit the field and every subscription weighs 1.

The aggregation refactor is the subtle part: the old combine fold
doubled as wakeup-registration and close-pruning (a subscriber
returning Pending was how its waiter got registered). Summing always
"changes" the result, which would break both. So registration and the
value merge are now decoupled: each live subscriber is polled with a
Pending closure purely to register the waiter and detect closure, then
its value is read and merged separately. This also fixes a latent
snapshot over-count when a unique-preference subscriber dropped.

Tests: wire round-trip across lite-05/lite-04 (leaf -> 0, paused -> 1),
model sum + prune-on-drop, and an end-to-end relay test standing up a
real moq-relay over WebTransport where two subscribers telescope to a
single count of 2 at the publisher (not per-hop) and back to 1 on
disconnect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Damp flapping when consumers (and thus the viewer count) churn rapidly.
After a relay sends a control message upstream for a subscription, it
holds further subscription changes for SUBSCRIBE_THROTTLE (1s), then
forwards the latest aggregate.

The serve loop suppresses subscription polling during the window and
re-polls when its timer fires. Because the aggregate is level-triggered,
a change that reverts within the window (e.g. a viewer joins and leaves)
produces no upstream message at all. handle_subscription now reports
whether it actually sent, so the window only arms on a real send.

Adds relay_throttles_subscribe_updates: a second viewer's count bump is
held behind the first's window, so the publisher can't observe it
near-instantly (lenient lower bound to stay CI-robust).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-count

# Conflicts:
#	rs/moq-net/src/lite/subscriber.rs
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