Skip to content

feat(stats): per-broadcast viewer lag and dropped media on egress rows - #4298

Merged
kixelated merged 8 commits into
quest/m1/qos/READMEfrom
quest/m1/qos/starvation
Sep 27, 2026
Merged

kixelated merged 8 commits into
quest/m1/qos/READMEfrom
quest/m1/qos/starvation

Conversation

@kixelated

@kixelated kixelated commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Completes /quest/m1/qos/starvation.md, the first child of the QoS line (#4133).

Problem

A relay counts bytes per broadcast but cannot say whether its viewers keep up. A viewer stalled on flow control and a healthy one look the same in publisher.json, and media abandoned before a viewer acknowledged it is invisible.

Approach

  • Frontier: each tagged egress track::Subscriber carries a stats::Frontier on its subscription guard: the newest frame timestamp the peer acknowledged, seeded with the track's newest frame when the subscription opens (a deliberate backlog never reads as lag).
  • Delivery: the lite and IETF group serve tasks hold a stats::Delivery per stream. Writer::poll_close succeeding (FIN acknowledged) advances the frontier; any other end (skip, expiry, peer stop, session close) drops it, adding the written span to dropped { duration, bytes, groups }.
  • Production: every frame write records the track's newest timestamp and cumulative bytes on cache::Track, the per-track handle every group already holds. It is unconditional because the egress sampler needs it even when ingress is untagged; spliced subscriptions register each segment's track as its cursor activates, so relay hops and failovers are covered.
  • Sampler: Registry::report (the moq-stats tick) walks the live frontiers and records max(newest - frontier, wall time the frontier has stood still while behind) into a byte-weighted lag histogram, weighted by bytes the tracks produced since the last tick. A paused source adds nothing; a viewer that caught up during the pause does not read the pause as lag afterwards.
  • stats::Histogram: fixed buckets at 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s, and above, each a monotonic byte counter; Traffic::add (and so the moq-stats aggregate) sums bucket by bucket. Built to be reused by publisher-timeliness.
  • Docs: doc/concept/stats.md (fields, a Lag section with the edges), doc/bin/relay/config.md ([stats]). Quest deleted, references updated, starvation-frames and publisher-timeliness point at the landed types.
  • Bench: stats/sample sweeps broadcasts x subscriptions per broadcast. Roughly 10ns per subscription per tick, linear.

Impact

  • moq_net::stats::Traffic gains lag: Histogram and dropped: Dropped (additive, #[non_exhaustive]).
  • New moq_net::stats::Histogram (EDGES, buckets(), total(), is_empty()) and moq_net::stats::Dropped (duration, bytes, groups, is_empty(), #[non_exhaustive], like Content), both re-exported from moq_stats.
  • Registry::report is now also the lag sampler's tick (documented); snapshot does not sample.
  • moq-net depends on serde_with (already a workspace dependency).
  • Wire (stats JSON only, no moq-lite/IETF change): publisher.json entries gain lag (object keyed by bucket upper edge, empty buckets omitted) and dropped: { duration (fractional ms), bytes, groups }. Both are omitted while empty, so existing frames are unchanged until something is recorded; older readers ignore them.

Alternatives

  • Production on stats::Meter, as the quest suggested: the meter is per broadcast and side, not per track, and is empty when ingress is untagged, which is exactly when the egress sampler still needs it.
  • Sampling in the write path or on ACK: a flow-controlled viewer never writes or acks, so it would vanish from the histogram (the quest's reasoning).
  • A fixed array on the wire: shorter, but a merge patch replaces arrays whole and the edges would not be self-describing.

Decisions (resolved by the maintainer)

  • Drops nest as dropped { duration, bytes, groups }, like stale.
  • Kept: the lag name, the keyed-object histogram, the edges (upper-exclusive), production on cache::Track, the dropped span (oldest to newest written frame of a group), excluding datagrams, and the wall-clock floor (max(media lag, wall time the frontier stood still while behind)).
  • Base: main via the line; the line was brought current with origin/main so this PR shows only its own diff.

Follow-ups

  • Starvation at frame granularity and publisher timeliness are next in the line; both now point at stats::Delivery / stats::Histogram.
  • The demo dashboard (demo/web/src/stats.ts) does not show lag or drops yet.
  • A subscription closing between ticks loses its last partial interval; a final sample on drop would need the wall clock in Drop.

Platform: moq-net and moq-stats compiled for the host only; CI covers wasm.

🤖 Generated with Claude Code

(Written by Opus 5.5)

kixelated and others added 6 commits September 26, 2026 13:47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	rs/moq-net/src/ietf/publisher.rs
#	rs/moq-net/src/stats.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review September 26, 2026 22:05
@kixelated

Copy link
Copy Markdown
Collaborator Author

Decisions:

  • Keep re-exporting Dropped from moq_stats next to Histogram.
  • Follow-ups accepted for planning: lag percentiles and drop counts in the demo stats dashboard; a final lag sample when a subscription closes.

(Written by Opus 5.5)

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-27T02:30:37.622682Z bee6fdf New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d7533189b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-net/src/stats.rs
Comment on lines +1893 to +1896
state.sources.retain_mut(|source| {
// Only this Weak outliving the track means it can never produce again.
let Some(track) = source.track.upgrade() else {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop sampling sources after a splice retires them

When a spliced subscription switches to a new segment while the old underlying track remains alive and publishing, this sampler retains the old source because it removes sources only when their tracks are dropped. Bytes and timestamps produced beyond the retired segment's boundary therefore continue contributing to this viewer's weight and lag, even though the viewer can no longer read that source. Track the currently active segment bounds or explicitly unwatch retired sources so route failovers do not permanently corrupt the lag histogram.

Useful? React with 👍 / 👎.

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.

Agreed. Fixed in c4eaffe: a segment unwatches its track when a switch caps it, and only the open-ended segment is watched on activation. lag_weighs_every_segment now writes to the capped route after the switch and asserts the weight does not move (fails without the fix, 6 vs 4).

(Written by Claude Opus 5.5)

// frame::Producer writes them.
// frame::Producer writes them. Production counts the declared size up front.
self.stats.frames(1);
self.cache.production().record(timestamp, frame.size);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count streamed production as payload arrives

When a streamed frame is opened but sends only part of its payload, stalls, or is dropped before completion, this records the entire declared size immediately even though frame::Producer counts actual ingress bytes chunk by chunk and aborts unfinished frames. The lag histogram is therefore weighted with bytes that were never produced, and an aborted frame with a future timestamp can leave the recorded newest edge artificially ahead. Update production from actual chunk writes rather than charging the declared size at frame creation; the owned creation path has the same issue.

Useful? React with 👍 / 👎.

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.

Keeping this as is. Charging the declared size and timestamp at frame open matches the rest of the group model: the cache charges frame.size up front and state.stamp(timestamp) places the group in time from the header before any payload streams in. A frame that is opened and then aborted is rare, and the error is bounded to that one frame for one tick. Counting per chunk would need production accounting inside frame::Producer for a marginal gain.

(Written by Claude Opus 5.5)

…s it

A capped route can keep publishing past the boundary; its production never
reaches this reader, so it no longer counts toward lag weight or the newest edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c4eaffea47

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-net/src/stats.rs Outdated
Comment on lines +1881 to +1883
state
.sources
.retain(|s| s.track.upgrade().is_some_and(|live| !Arc::ptr_eq(&live, track)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unsampled bytes when unwatching a splice source

When a route switch caps a segment after it has produced data since the previous Registry::report, removing its Source here also discards the pending production.bytes - sampled delta. Since the new open-ended segment is normally reconciled before the next stats tick, that old-route payload never enters the lag histogram, causing its total weight to undercount each failover. The final code now invokes unwatch on every open-ended-to-capped transition, which is fresh evidence beyond the prior stale-source issue; preserve a final production snapshot through the next sample or flush the pending weight at the cap. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

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.

Agreed. Fixed in bee6fdf: unwatch folds the source's unsampled bytes into the frontier, and the next sample weighs them. The test now writes to the old route before the switch and asserts those bytes still count.

(Written by Claude Opus 5.5)

Comment thread rs/moq-net/src/stats.rs Outdated
Comment on lines +1909 to +1912
let bytes = production.bytes.load(Ordering::Relaxed);
weight += bytes.saturating_sub(source.sampled);
source.sampled = bytes;
newest = newest.max(Production::load(&production.newest));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Synchronize production metadata before consuming byte weight

When frame production and Registry::report run on different threads, these relaxed loads can observe the incremented bytes while still seeing the pre-record newest or first, because the three atomics have no cross-location synchronization. The sampler nevertheless advances source.sampled, then either assigns those bytes to a stale edge or returns at newest?, so a later tick cannot correct the sample. Publish the metadata and byte increment as a coherent snapshot, or use release/acquire ordering before advancing sampled. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

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.

Agreed. Fixed in bee6fdf: Production::record publishes bytes with release after the edges, and the sampler loads it with acquire before reading them.

(Written by Claude Opus 5.5)

if let Err(err) = buffered {
break 'serve Err(err);
}
self.delivery.wrote(next.timestamp, next.size);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count dropped bytes only after transport writes them

Independently of the existing production-accounting issue, when a stream is flow-controlled or a streamed frame has not supplied its payload yet, this records the entire declared size immediately after buffering only the frame header; payload is submitted to writer.poll_write later. If the serve task is reset or dropped first, Delivery::drop charges all of those bytes and the frame timestamp as media written to the viewer even though none, or only part, reached the transport. The batched path and IETF publisher use the same ordering, so delivery accounting should follow successful payload writes rather than header creation. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

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.

Keeping this as is. A frame whose header was committed to the stream is lost to the viewer as a whole if the stream is reset, however much of its payload went out, so charging its declared size is the intended meaning of dropped. Per-byte accounting would add a counter to the payload write path and change no decision the metric drives.

(Written by Claude Opus 5.5)

…n edges

Unwatching a capped splice segment carried away the bytes it produced since
the last sample; carry them into the next one. Publish production bytes with
release and read them with acquire so the sampler never weighs bytes against
edges that predate them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Landing summary:

  • c4eaffe: a spliced subscription stops weighing a segment's track once a switch caps it, so a route that keeps publishing past the boundary no longer inflates lag weight or the newest edge. Only the open-ended segment is watched on activation.
  • bee6fdf: unwatching carries the source's unsampled bytes into the next sample; production bytes are published with release and sampled with acquire.
  • lag_weighs_every_segment covers both (fails without each fix).
  • Declined two review points (charging declared frame size at open, for production and for drops), with replies inline.
  • No public API or wire change beyond what the description lists.

(Written by Claude Opus 5.5)

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bee6fdfbe7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-net/src/stats.rs
/// nothing was produced (a paused source moves nothing).
fn sample(&self, now: Instant) -> Option<(Duration, u64)> {
let mut state = self.state.lock().expect("stats frontier poisoned");
let mut weight = std::mem::take(&mut state.unsampled);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve pending weight until a source has an edge

Fresh evidence after the pending-byte fix: sample destructively takes state.unsampled before proving that a current source supplies newest and first. If the old segment produced bytes since the previous tick, is capped, and the replacement is activated before producing its first frame, newest remains None and the later ? returns after clearing the pending weight; no subsequent sample can recover those bytes. Leave unsampled intact until a sample can be recorded, or retain the retired source's edge with its weight. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L28-L28

Useful? React with 👍 / 👎.

Comment on lines +1672 to +1675
if existing.end.is_none()
&& let SubState::Active(sub) = &mut existing.sub
{
sub.unwatch(&existing.frontier);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Unwatch segments replaced before their first frame

When an active segment has not produced in-range media, ResumeState::switch may replace it outright rather than assigning an end; apply then marks its SegmentSub pruned and removes it via retired() before this end-change-only unwatch runs. If that still-live route subsequently publishes while racing the replacement, its source remains registered and those unreachable bytes corrupt this viewer's lag histogram despite the capped-segment fix. Unwatch active segments when pruning or replacing them as well. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L28-L28

Useful? React with 👍 / 👎.

@kixelated
kixelated merged commit 88a5269 into quest/m1/qos/README Sep 27, 2026
7 checks passed
@kixelated
kixelated deleted the quest/m1/qos/starvation branch September 27, 2026 04:03
kixelated added a commit that referenced this pull request Sep 28, 2026
Starvation finished on this line (#4298), so drop it from Required and from the new final-lag-sample and lag-dashboard dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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