Skip to content

feat(moq-net): enforce the subscriber latency budget - #2890

Merged
kixelated merged 32 commits into
devfrom
claude/latency-enforcement
Aug 19, 2026
Merged

feat(moq-net): enforce the subscriber latency budget#2890
kixelated merged 32 commits into
devfrom
claude/latency-enforcement

Conversation

@kixelated

@kixelated kixelated commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Subscription::latency / latencyMax was propagated as metadata but the track model never acted on it. This enforces it in rs/moq-net at both ends of a subscription, which is the ceiling that #2784 needs now that #2785 is reverted on dev.

The JS mirror is stacked on top as #2919 (originally filed as #2892), so each language reviews on its own.

  • Group selection. track::Subscriber's poll_recv_group / poll_next_group / poll_read_frame skip a group that has drifted past the subscription's budget, so one poll walks a whole backlog off. Both ends go through the same code: a relay serves downstream via poll_recv_group, and a local consumer reads the same way. fetch_group is exempt, since a fetch names historical content explicitly.
  • Two measures. Presentation time (a group's first frame against the newest one above it) and wall-clock arrival time, either of which can expire a group. The arrival clock is monotonic, so it does not react to system-clock changes, and it backstops a stalled or empty group that has no timestamp yet.
  • Measured from the reader, not the group. A group is not late because it opened long ago; what can be late is the content its reader has yet to take. A cursor that has drained a group sits at the group's newest frame. Measuring from the first frame instead makes a 2s GOP two seconds behind by construction, so any budget shorter than one GOP drops the tail of every GOP. A group nobody has started reading still sits at its own start, which is what leaves selection unchanged.
  • After handoff, only a read that would park is judged. A group with frames in hand always drains: the budget bounds a group that has stalled while the live edge moved on, not a reader slower than the wire. Wire publishers keep the check at the one stall the group cursor cannot see, parking on transport flow control, so a blocked stream cannot pin credit for content that has gone stale.
  • A drained group ends rather than fails. A group-level read only parks once the cursor has taken every frame, so expiring there costs the reader nothing: what it was waiting for was the producer's FIN, and a group abandoned at its own end is indistinguishable from one that ended there. Error::Old is reserved for a cursor still holding unread content (a half-read payload, a publisher with buffered frames). Without this a relay reset the downstream stream for a group it had delivered in full, since the FIN and the next group's first frame are separate streams and the reader is routinely parked at the group's end when the verdict is taken.
  • Route takeovers. Every replacement copy in a spliced group inherits the logical subscription, reader cap, and route-segment bound, while ordinary cache eviction can still fail over to another copy.
  • Consumers. The mux, native audio/video decoders, libmoq, GStreamer, and the shipped Hang subscriber example apply their configured latency to the initial subscription. moq_mux::container::Consumer::new inherits that already-negotiated budget.
  • Stats. Skipped content is reported as stale: { bytes, frames, groups, datagrams } in JSON stats and as matching moq_relay_stale_*_total Prometheus counters. Expiry after handoff counts only the unread tail, and cloned readers share the one attribution.

Public API changes

Targets dev for one breaking cleanup:

  • Breaking: remove moq_mux::container::Consumer::with_latency. Consumer::new now inherits the initial moq_net::track::Subscription latency; set_latency remains for mid-stream changes.
  • New moq_net::stats::Content counter shape, and a stats::Traffic::stale: Content field on the existing #[non_exhaustive], serde-defaulted struct.
  • New kio::ConsumerWeak::poll, mirroring Consumer::poll. Additive: it lets a watcher register for changes without joining the consumer count.

Wire behavior changes

The encoding is unchanged; no draft update is needed, and the existing draft already specifies the two age backstops. What a peer observes differently:

  • A group that has drifted past the subscriber's requested budget is not sent at all. The default budget is zero, so a delayed live subscriber deliberately advances to the live edge; callers that need history must request a replay window.
  • A group stream is reset with Old when the group expires while its transport write is flow-control blocked, or while waiting on stream credit. It was previously held open.
  • A group that expires with nothing unread is FIN'd rather than reset, so a relay no longer signals truncation for a group it delivered in full.
  • moq-transport carries no receiver latency parameter, so serving-side IETF subscriptions request an effectively unbounded but wire-encodable window and leave receiver-specific enforcement to the receiving subscriber.

Test plan

  • nix develop --command just check / just check-all
  • nix develop --command just test all: 3258 Rust tests pass, 2 skipped; all JS suites green.
  • Regression tests cover partial-frame, blocked-send, blocked-final-payload, exhausted-stream-credit and transport-flow-control-blocked expiry, fetch-only/live-edge isolation, system-clock changes, handed-out groups, clean EOF after a final-frame drain, unread-tail and cloned-reader stats, replacement copies after route takeover, latency-update wakeups, and the relay Prometheus renderer.
  • Two of them were verified to fail with only their own half of the fix reverted: real_time_reads_a_live_stream_without_truncating_it (2s GOPs read as they arrive at the default budget, with the FIN landing after the successor's first frame) and a_budget_is_measured_from_the_readers_position (a straggler frame arriving after the next group opened still reaches a reader inside its budget).
  • Measured on the frame-read hot path, 20k frames with 50 groups cached: 13ns/frame on dev, 9-14ns here.

(Written by Opus 5)

Loading
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