From a3bc1ebec108199e258ed2773d1f08b210b1a351 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:56:10 -0700 Subject: [PATCH 01/11] quest: claim m1/track-priority-scope From 5afe4107bbd3dcd963af59cc0d4a8b080dc89820 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:48:22 -0700 Subject: [PATCH 02/11] wip: signed track priorities and per-broadcast relay scheduling (Rust) Co-Authored-By: Claude Opus 5.5 --- rs/hang/src/catalog/priority.rs | 17 +- rs/hang/src/container/frame.rs | 4 +- rs/moq-archive/src/error.rs | 4 +- rs/moq-archive/src/info.rs | 45 ++- rs/moq-archive/src/proof.rs | 2 +- rs/moq-archive/src/store.rs | 4 +- rs/moq-archive/src/writer.rs | 6 +- rs/moq-c/README.md | 2 +- rs/moq-c/src/api.rs | 17 +- rs/moq-c/src/consume.rs | 6 +- rs/moq-c/src/publish.rs | 2 +- rs/moq-c/src/test.rs | 6 +- rs/moq-ffi/src/bench.rs | 2 +- rs/moq-ffi/src/consumer.rs | 6 +- rs/moq-ffi/src/producer.rs | 12 +- rs/moq-ffi/src/test.rs | 6 +- rs/moq-ffi/src/transport/adapter.rs | 5 +- rs/moq-mux/src/catalog/producer.rs | 2 +- rs/moq-mux/src/catalog/tracks.rs | 2 +- rs/moq-mux/src/container/fmp4/mod.rs | 2 +- rs/moq-mux/src/container/mkv/import.rs | 2 +- rs/moq-mux/src/container/test_util.rs | 12 +- rs/moq-mux/src/import/track.rs | 2 +- rs/moq-net/benches/priority.rs | 8 +- rs/moq-net/benches/schedule.rs | 6 +- rs/moq-net/benches/track.rs | 4 +- rs/moq-net/src/client.rs | 30 +- rs/moq-net/src/coding/writer.rs | 5 + rs/moq-net/src/ietf/adapter.rs | 12 +- rs/moq-net/src/ietf/fetch.rs | 2 +- rs/moq-net/src/ietf/group.rs | 2 +- rs/moq-net/src/ietf/priority.rs | 50 ++- rs/moq-net/src/ietf/publish.rs | 2 +- rs/moq-net/src/ietf/publisher.rs | 90 +++-- rs/moq-net/src/ietf/subscribe.rs | 4 +- rs/moq-net/src/ietf/subscriber.rs | 10 +- rs/moq-net/src/lib.rs | 2 + rs/moq-net/src/lite/fetch.rs | 6 +- rs/moq-net/src/lite/mod.rs | 1 + rs/moq-net/src/lite/priority.rs | 34 ++ rs/moq-net/src/lite/publisher.rs | 372 +++++++++++++++++- rs/moq-net/src/lite/session.rs | 11 + rs/moq-net/src/lite/subscribe.rs | 22 +- rs/moq-net/src/lite/subscriber.rs | 2 +- rs/moq-net/src/lite/test_transport.rs | 67 +++- rs/moq-net/src/lite/track.rs | 17 +- rs/moq-net/src/model/bandwidth.rs | 25 +- rs/moq-net/src/model/group.rs | 14 +- rs/moq-net/src/model/subscription.rs | 7 +- rs/moq-net/src/model/track.rs | 31 +- rs/moq-net/src/model/track/demand.rs | 4 +- rs/moq-net/src/priority.rs | 262 ++++++------ rs/moq-net/src/scheduler.rs | 135 +++++++ rs/moq-net/src/server.rs | 28 +- rs/moq-net/src/session.rs | 24 ++ rs/moq-net/src/transport.rs | 40 +- rs/moq-net/src/transport/memory.rs | 10 +- .../tests/integration/subscribe_priority.rs | 8 +- rs/moq-net/tests/integration/support/mock.rs | 6 +- rs/moq-quic/src/connection/mod.rs | 2 +- rs/moq-quic/src/connection/streams/mod.rs | 31 +- .../src/connection/streams/saturated.rs | 14 +- .../src/connection/streams/schedule.rs | 2 - rs/moq-quic/src/connection/streams/state.rs | 6 +- rs/moq-quic/src/lib.rs | 2 +- rs/moq-quic/src/tests/mod.rs | 4 +- rs/moq-relay/src/cluster.rs | 61 ++- rs/moq-relay/src/connection.rs | 3 +- rs/moq-relay/src/incoming.rs | 10 + rs/moq-relay/src/settings.rs | 3 + rs/moq-relay/src/uring.rs | 3 +- rs/moq-room/src/chat.rs | 2 +- rs/moq-tokio/src/client.rs | 6 + rs/moq-tokio/src/quic/driver/connection.rs | 18 +- rs/moq-tokio/src/quic/driver/mod.rs | 2 +- rs/moq-tokio/src/quic/driver/send_group.rs | 57 ++- rs/moq-tokio/src/quic/web/session.rs | 17 +- rs/moq-tokio/src/server.rs | 7 + rs/moq-tokio/src/transport.rs | 16 +- rs/moq-tokio/src/transport/grouped.rs | 16 +- rs/moq-tokio/src/transport/owned.rs | 11 +- rs/moq-uring/src/quic/driver/connection.rs | 18 +- rs/moq-uring/src/quic/driver/group.rs | 57 ++- rs/moq-uring/src/quic/driver/mod.rs | 2 +- rs/moq-uring/src/quic/mod.rs | 2 +- rs/moq-uring/src/quic/web.rs | 13 +- rs/moq-uring/src/transport.rs | 10 +- rs/moq-uring/src/transport/adapter.rs | 29 +- rs/moq-uring/src/transport/local.rs | 14 +- rs/moq-wasm/src/transport/adapter.rs | 5 +- 90 files changed, 1530 insertions(+), 434 deletions(-) create mode 100644 rs/moq-net/src/lite/priority.rs create mode 100644 rs/moq-net/src/scheduler.rs diff --git a/rs/hang/src/catalog/priority.rs b/rs/hang/src/catalog/priority.rs index ad88b4d907..9f4786452a 100644 --- a/rs/hang/src/catalog/priority.rs +++ b/rs/hang/src/catalog/priority.rs @@ -5,18 +5,19 @@ #[non_exhaustive] pub struct Priorities { /// Catalog track priority. - pub catalog: u8, + pub catalog: i8, /// Text (caption/subtitle) track priority. - pub text: u8, + pub text: i8, /// Audio track priority. - pub audio: u8, + pub audio: i8, /// Video track priority. - pub video: u8, + pub video: i8, } /// Default delivery priorities for hang tracks, with higher values sent first. /// -/// Text sits just below the catalog and above audio/video: cues are tiny and accessibility-critical, +/// All of them sit above 0, the unset default, so hang media outranks a track that never set +/// a priority. Text sits just below the catalog and above audio/video: cues are tiny and accessibility-critical, /// so they should never be starved behind a media backlog. pub const PRIORITY: Priorities = Priorities { catalog: 100, @@ -36,4 +37,10 @@ mod tests { assert_eq!(PRIORITY.audio, 80); assert_eq!(PRIORITY.video, 60); } + + #[test] + fn media_outranks_the_unset_default() { + let unset = moq_net::track::Info::default().priority; + assert!([PRIORITY.catalog, PRIORITY.text, PRIORITY.audio, PRIORITY.video].iter().all(|&p| p > unset)); + } } diff --git a/rs/hang/src/container/frame.rs b/rs/hang/src/container/frame.rs index e03900bf00..b63959c1e1 100644 --- a/rs/hang/src/container/frame.rs +++ b/rs/hang/src/container/frame.rs @@ -34,10 +34,10 @@ pub const TIMESCALE: Timescale = Timescale::MICRO; /// [`PRIORITY`](crate::catalog::PRIORITY) so every publisher orders its kinds the /// same way (`PRIORITY.video` for a video track, and so on). It's a parameter /// rather than a default because there is no correct value for "some media -/// track": leaving it at zero is what puts audio and video in one undifferentiated +/// track": leaving it at the unset 0 is what puts audio and video in one undifferentiated /// tier, both on the wire and in /// [`bandwidth::Allocator`](moq_net::bandwidth::Allocator). -pub fn track_info(priority: u8) -> moq_net::track::Info { +pub fn track_info(priority: i8) -> moq_net::track::Info { moq_net::track::Info::default() .with_timescale(TIMESCALE) .with_max_age(MAX_AGE) diff --git a/rs/moq-archive/src/error.rs b/rs/moq-archive/src/error.rs index e33439b030..75ae9e3500 100644 --- a/rs/moq-archive/src/error.rs +++ b/rs/moq-archive/src/error.rs @@ -26,9 +26,9 @@ pub enum Error { #[error("priority {existing} does not match {intended}")] Priority { /// The priority already recorded. - existing: u8, + existing: i8, /// The priority the caller wanted to record. - intended: u8, + intended: i8, }, /// An existing `.info` has a different timescale, `None` when untimed. diff --git a/rs/moq-archive/src/info.rs b/rs/moq-archive/src/info.rs index cfd546005b..1513f95344 100644 --- a/rs/moq-archive/src/info.rs +++ b/rs/moq-archive/src/info.rs @@ -10,8 +10,12 @@ use crate::{Error, Result, VERSION}; pub struct Info { /// Recording format version; must be [`VERSION`]. pub version: u64, - /// Publisher tie-break priority, 0 through 255. - pub priority: u8, + /// The publisher's track priority, higher first. + /// + /// Stored as its moq-lite byte, `priority + 128`, which is what recordings made before + /// priorities were signed stored, so they keep their meaning. + #[serde(with = "priority")] + pub priority: i8, /// Units per second for this track's timestamps, 1 through 2^53 - 1, or `None` when the track /// is untimed and its frames store no timestamps. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -23,7 +27,7 @@ pub struct Info { impl Info { /// The current format version with `priority` and `timescale`, `None` for an untimed track. - pub fn new(priority: u8, timescale: Option) -> Result { + pub fn new(priority: i8, timescale: Option) -> Result { let info = Self { version: VERSION, priority, @@ -67,6 +71,19 @@ impl Info { } } +/// The priority as its moq-lite byte. +mod priority { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(priority: &i8, serializer: S) -> Result { + serializer.serialize_u8((*priority as u8) ^ 0x80) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Ok((u8::deserialize(deserializer)? ^ 0x80) as i8) + } +} + /// The epoch as its UUID text. mod epoch { use serde::{Deserialize, Deserializer, Serializer, de::Error}; @@ -95,7 +112,7 @@ mod tests { fn roundtrip() { let info = Info::new(0, Some(1_000_000)).unwrap(); let bytes = info.encode().unwrap(); - assert_eq!(bytes.as_ref(), br#"{"version":2,"priority":0,"timescale":1000000}"#); + assert_eq!(bytes.as_ref(), br#"{"version":2,"priority":128,"timescale":1000000}"#); assert_eq!(Info::decode(&bytes).unwrap(), info); } @@ -103,7 +120,7 @@ mod tests { fn an_untimed_track_omits_its_timescale() { let info = Info::new(3, None).unwrap(); let bytes = info.encode().unwrap(); - assert_eq!(bytes.as_ref(), br#"{"version":2,"priority":3}"#); + assert_eq!(bytes.as_ref(), br#"{"version":2,"priority":131}"#); assert_eq!(Info::decode(&bytes).unwrap(), info); } @@ -114,10 +131,24 @@ mod tests { info, Info::decode(br#"{ "version": 2, "priority": 7, "timescale": 1000 }"#).unwrap() ); - assert_eq!(info.priority, 7); + assert_eq!(info.priority, -121); assert_eq!(info.timescale, Some(1000)); } + #[test] + fn priority_is_stored_as_its_lite_byte() { + for (priority, byte) in [(i8::MIN, 0), (-1, 127), (0, 128), (i8::MAX, 255)] { + let info = Info::new(priority, None).unwrap(); + let json = format!(r#"{{"version":2,"priority":{byte}}}"#); + assert_eq!(info.encode().unwrap().as_ref(), json.as_bytes()); + assert_eq!(Info::decode(json.as_bytes()).unwrap(), info); + } + assert!(matches!( + Info::decode(br#"{"version":2,"priority":256}"#), + Err(Error::Json(_)) + )); + } + #[test] fn unknown_version_is_refused() { for version in [1, 3] { @@ -133,7 +164,7 @@ mod tests { let bytes = info.encode().unwrap(); assert_eq!( bytes.as_ref(), - br#"{"version":2,"priority":0,"timescale":1000,"epoch":"01900000-0000-7000-8000-000000000001"}"# + br#"{"version":2,"priority":128,"timescale":1000,"epoch":"01900000-0000-7000-8000-000000000001"}"# ); assert_eq!(Info::decode(&bytes).unwrap(), info); assert!(matches!( diff --git a/rs/moq-archive/src/proof.rs b/rs/moq-archive/src/proof.rs index d72b8ab66c..ed3eff5253 100644 --- a/rs/moq-archive/src/proof.rs +++ b/rs/moq-archive/src/proof.rs @@ -204,7 +204,7 @@ async fn recordings_are_byte_identical_on_every_backend() { assert_eq!(expected.keys().cloned().collect::>(), layout()); assert_eq!( &expected["rec/video%2F360p/.info"][..], - br#"{"version":2,"priority":127,"timescale":1000}"# + br#"{"version":2,"priority":128,"timescale":1000}"# ); let dir = tempfile::tempdir().unwrap(); diff --git a/rs/moq-archive/src/store.rs b/rs/moq-archive/src/store.rs index 355f338c37..ef9c895c3a 100644 --- a/rs/moq-archive/src/store.rs +++ b/rs/moq-archive/src/store.rs @@ -417,7 +417,7 @@ mod tests { .inner() .put( &path, - br#"{ "timescale": 1000, "priority": 1, "version": 2 }"#.as_ref().into(), + br#"{ "timescale": 1000, "priority": 129, "version": 2 }"#.as_ref().into(), ) .await .unwrap(); @@ -428,7 +428,7 @@ mod tests { .bytes() .await .unwrap(); - assert_eq!(&kept[..], br#"{ "timescale": 1000, "priority": 1, "version": 2 }"#); + assert_eq!(&kept[..], br#"{ "timescale": 1000, "priority": 129, "version": 2 }"#); } #[tokio::test] diff --git a/rs/moq-archive/src/writer.rs b/rs/moq-archive/src/writer.rs index 013d5cc8e6..96773d3b22 100644 --- a/rs/moq-archive/src/writer.rs +++ b/rs/moq-archive/src/writer.rs @@ -1247,7 +1247,7 @@ mod tests { assert_eq!(object.groups[0].frames[1].payload, "2@2500"); assert_eq!( store.get_info("video").await.unwrap(), - Info::new(127, Some(1000)).unwrap() + Info::new(0, Some(1000)).unwrap() ); store.get_info(&timeline("video")).await.unwrap(); assert!(store.get_info("ignored").await.is_err()); @@ -1304,7 +1304,7 @@ mod tests { assert!(object.groups[0].frames.iter().all(|frame| frame.timestamp.is_none())); assert_eq!(object.groups[0].frames[2].payload, "3/2"); - assert_eq!(store.get_info("data").await.unwrap(), Info::new(127, None).unwrap()); + assert_eq!(store.get_info("data").await.unwrap(), Info::new(0, None).unwrap()); let timeline = timeline("data"); assert_eq!(store.get_info(&timeline).await.unwrap().timescale, None); let stored = store.get_segments(&timeline, 0, false).await.unwrap(); @@ -1754,7 +1754,7 @@ mod tests { control.track("video", media()).await, Err(Error::Priority { existing: 7, - intended: 127 + intended: 0 }) ); } diff --git a/rs/moq-c/README.md b/rs/moq-c/README.md index 1dee9eee17..43d5ee36ff 100644 --- a/rs/moq-c/README.md +++ b/rs/moq-c/README.md @@ -104,7 +104,7 @@ int32_t moq_track_request_audio(uint32_t request, const moq_audio_init *config); int32_t moq_track_request_abort(uint32_t request, uint16_t error_code); int32_t moq_track_request_free(uint32_t request); int32_t moq_group_request_sequence(uint32_t request, uint64_t *dst); -int32_t moq_group_request_priority(uint32_t request, uint8_t *dst); +int32_t moq_group_request_priority(uint32_t request, int8_t *dst); int32_t moq_group_request_frame_start(uint32_t request, uint64_t *dst); int32_t moq_group_request_accept(uint32_t request); int32_t moq_group_request_abort(uint32_t request, uint16_t error_code); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 7948ed0c53..b104d6decf 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -507,14 +507,14 @@ pub struct moq_datagram { /// Publisher-side raw track properties. /// -/// A null [moq_publish_track] `info` pointer uses the moq-net defaults. -/// A zero-initialized struct also uses those defaults, except `priority`, which -/// has no presence flag: zero is the least urgent, and 127 is the moq-net default. +/// A null [moq_publish_track] `info` pointer uses the moq-net defaults, and so does a +/// zero-initialized struct. #[repr(C)] #[allow(non_camel_case_types)] pub struct moq_track_info { /// Priority, used to break ties between subscriptions of equal subscriber priority. - pub priority: u8, + /// Higher is more urgent; the default 0 is the midpoint. + pub priority: i8, /// Maximum age of a non-latest group before the publisher evicts it, in microseconds. /// The publisher-side half of `moq_subscription.max_delay_us`. @@ -579,8 +579,9 @@ pub enum moq_demand { #[repr(C)] #[allow(non_camel_case_types)] pub struct moq_subscription { - /// Delivery priority. Higher values preempt lower ones under contention. - pub priority: u8, + /// Delivery priority. Higher values preempt lower ones under contention; the default 0 is + /// the midpoint. + pub priority: i8, /// How far a non-latest group may fall behind the live edge before it is skipped, in microseconds. /// Zero skips immediately. Enforced by the publisher's cache and by any local buffering. @@ -3008,9 +3009,9 @@ pub unsafe extern "C" fn moq_group_request_sequence(request: u32, dst: *mut u64) /// Returns a zero on success, or a negative code on failure. /// /// # Safety -/// - `dst` must point at a writable `uint8_t`. +/// - `dst` must point at a writable `int8_t`. #[unsafe(no_mangle)] -pub unsafe extern "C" fn moq_group_request_priority(request: u32, dst: *mut u8) -> i32 { +pub unsafe extern "C" fn moq_group_request_priority(request: u32, dst: *mut i8) -> i32 { ffi::enter(move || { let request = ffi::parse_id(request)?; let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?; diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 4b248c878c..7ca82090a5 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -53,7 +53,7 @@ impl ConsumeCatalog { /// A catalog media section whose renditions are consumed by index. pub(crate) trait Media: hang::catalog::Reference + Clone { /// The delivery priority its tracks subscribe with. - const PRIORITY: u8; + const PRIORITY: i8; /// Its renditions, keyed by ID. fn renditions(catalog: &moq_mux::catalog::hang::Catalog) -> &BTreeMap; @@ -63,7 +63,7 @@ pub(crate) trait Media: hang::catalog::Reference + Clone { } impl Media for hang::catalog::VideoConfig { - const PRIORITY: u8 = hang::catalog::PRIORITY.video; + const PRIORITY: i8 = hang::catalog::PRIORITY.video; fn renditions(catalog: &moq_mux::catalog::hang::Catalog) -> &BTreeMap { &catalog.video.renditions @@ -75,7 +75,7 @@ impl Media for hang::catalog::VideoConfig { } impl Media for hang::catalog::AudioConfig { - const PRIORITY: u8 = hang::catalog::PRIORITY.audio; + const PRIORITY: i8 = hang::catalog::PRIORITY.audio; fn renditions(catalog: &moq_mux::catalog::hang::Catalog) -> &BTreeMap { &catalog.audio.renditions diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index 6ed3170567..ad7812d2fe 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -673,7 +673,7 @@ impl Publish { } /// The sequence, priority, and first frame of a requested group. - pub fn group_request_info(&self, request: Id) -> Result<(u64, u8, u64), Error> { + pub fn group_request_info(&self, request: Id) -> Result<(u64, i8, u64), Error> { let request = self.group_request.get(request).ok_or(Error::NotFound)?; Ok((request.sequence(), request.priority(), request.frame_start())) } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 366481f9cd..89a75af383 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -2594,7 +2594,7 @@ fn track_demand_reports_the_wanted_group() { let broadcast = publish_broadcast(origin, b"track-demand-wanted"); let name = b"data"; let info = moq_track_info { - priority: 127, + priority: 0, max_age_us: 0, max_age_present: false, timescale: 0, @@ -2898,7 +2898,7 @@ fn fetch_group( broadcast: u32, name: &str, sequence: u64, - priority: u8, + priority: i8, frame_start: u64, ) -> mpsc::Receiver), moq_net::Error>> { let consumer = State::lock() @@ -2939,7 +2939,7 @@ fn track_dynamic_serves_a_fetch_miss() { let fetch = fetch_group(broadcast, "data", 5, 11, 0); let request = id(group_cb.recv()); let mut sequence = 0u64; - let mut priority = 0u8; + let mut priority = 0i8; let mut frame_start = 1u64; assert_eq!(unsafe { moq_group_request_sequence(request, &mut sequence) }, 0); assert_eq!(unsafe { moq_group_request_priority(request, &mut priority) }, 0); diff --git a/rs/moq-ffi/src/bench.rs b/rs/moq-ffi/src/bench.rs index 86b0d8e58c..c03b5f3d96 100644 --- a/rs/moq-ffi/src/bench.rs +++ b/rs/moq-ffi/src/bench.rs @@ -240,7 +240,7 @@ async fn rust_ffi() { }); let broadcast = origin.create_broadcast("bench".into()).unwrap(); let timed = MoqTrackInfo { - priority: 127, + priority: 0, max_age_us: None, refresh: None, timescale: Some(1_000_000), diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 728f08e199..c1c71d1bfc 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -26,8 +26,9 @@ fn raw_frame(frame: moq_net::frame::Frame) -> Result { #[derive(Clone, uniffi::Record)] pub struct MoqSubscription { /// Delivery priority; higher values preempt lower ones under bandwidth contention. + /// The default 0 is the midpoint. #[uniffi(default = 0)] - pub priority: u8, + pub priority: i8, /// Maximum age of a non-latest group before it is skipped, in microseconds. /// `0` skips immediately; a larger value tolerates that much reordering. /// @@ -110,8 +111,9 @@ impl From for moq_net::track::Order { #[derive(Clone, uniffi::Record)] pub struct MoqFetchGroupOptions { /// Delivery priority for the fetch stream; higher values preempt lower ones. + /// The default 0 is the midpoint. #[uniffi(default = 0)] - pub priority: u8, + pub priority: i8, /// Maximum age behind the live edge in microseconds; null uses publisher retention alone. #[uniffi(default = None)] pub max_delay_us: Option, diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index e3468a11de..77652fddda 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -11,13 +11,13 @@ use crate::media::MoqFrame; /// Publisher-side track properties, mirroring [`moq_net::track::Info`]. /// /// Construct with the fields you care about; the rest use raw-track defaults -/// (priority 127, no publisher age limit, untimed). +/// (priority 0, no publisher age limit, untimed). #[derive(Clone, uniffi::Record)] pub struct MoqTrackInfo { /// Priority, used only to break ties between subscriptions of equal subscriber priority. - /// Higher is more urgent; the default 127 is the midpoint. - #[uniffi(default = 127)] - pub priority: u8, + /// Higher is more urgent; the default 0 is the midpoint. + #[uniffi(default = 0)] + pub priority: i8, /// Maximum age of a non-latest group before the publisher evicts it, in /// microseconds. Null imposes no publisher age limit. This is the publisher-side half of /// [`MoqSubscription::max_delay_us`](crate::consumer::MoqSubscription::max_delay_us). @@ -342,7 +342,7 @@ impl MoqTrackDynamic { #[derive(uniffi::Object)] pub struct MoqGroupRequest { sequence: u64, - priority: u8, + priority: i8, inner: std::sync::Mutex>, } @@ -368,7 +368,7 @@ impl MoqGroupRequest { } /// The consumer's delivery priority for this fetch. - pub fn priority(&self) -> u8 { + pub fn priority(&self) -> i8 { self.priority } diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 404a497b70..deb900532a 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -31,7 +31,7 @@ fn timed() -> moq_net::track::Info { /// An explicitly timed raw track, whose frame API accepts microsecond timestamps. fn timed_info() -> MoqTrackInfo { MoqTrackInfo { - priority: 127, + priority: 0, max_age_us: None, refresh: None, timescale: Some(1_000_000), @@ -962,7 +962,7 @@ async fn data_writes_preserve_optional_timestamps() { .publish_track( "data".into(), Some(MoqTrackInfo { - priority: 127, + priority: 0, max_age_us: None, refresh: None, timescale: timestamp_us.map(|_| 1_000_000), @@ -1103,7 +1103,7 @@ async fn json_snapshot_roundtrip() { compression: true, }; let info = MoqTrackInfo { - priority: 127, + priority: 0, max_age_us: None, timescale: Some(1_000_000), refresh: None, diff --git a/rs/moq-ffi/src/transport/adapter.rs b/rs/moq-ffi/src/transport/adapter.rs index 7b6a08e2e1..8028b140ed 100644 --- a/rs/moq-ffi/src/transport/adapter.rs +++ b/rs/moq-ffi/src/transport/adapter.rs @@ -85,7 +85,10 @@ impl + Clone + 'st .map_err(Error) .map(|res| res.map(|(send, recv)| (SendStream(send).scheduled(options), RecvStream(recv)))) } - fn send_group(&mut self, _: i64) -> Option { + fn send_domain(&mut self) -> Option { + None + } + fn send_group(&mut self, _: Option<&poll::Ungrouped>, _: i64) -> Option { None } /// The backend cannot rank a datagram against a stream, so `options` are ignored, and a diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 6331feae6d..c658bacfa0 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -479,7 +479,7 @@ impl Producer { /// `priority` should come from [`PRIORITY`](hang::catalog::PRIORITY) for the kind of media /// the track carries. Chain [`with_timescale`](moq_net::track::Info::with_timescale) for a /// container that carries the source's own scale (CMAF and Matroska both do). - pub fn track_info(&self, priority: u8) -> moq_net::track::Info { + pub fn track_info(&self, priority: i8) -> moq_net::track::Info { let info = hang::container::track_info(priority); match self.max_age { Some(max_age) => info.with_max_age(max_age), diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs index 70d6a478e1..f9f72764d1 100644 --- a/rs/moq-mux/src/catalog/tracks.rs +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -396,7 +396,7 @@ impl Reserved { /// Track properties for a media track under this catalog, carrying any retention it declares. /// See [`Producer::track_info`](super::Producer::track_info). - pub fn track_info(&self, priority: u8) -> moq_net::track::Info { + pub fn track_info(&self, priority: i8) -> moq_net::track::Info { self.timebase.catalog.track_info(priority) } diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 74d8d5d74a..4e4d59d733 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -283,7 +283,7 @@ impl Kind { } /// The publisher priority for this kind of media. - fn priority(self) -> u8 { + fn priority(self) -> i8 { match self { Self::Video => hang::catalog::PRIORITY.video, Self::Audio => hang::catalog::PRIORITY.audio, diff --git a/rs/moq-mux/src/container/mkv/import.rs b/rs/moq-mux/src/container/mkv/import.rs index d2668937ff..9f287063cb 100644 --- a/rs/moq-mux/src/container/mkv/import.rs +++ b/rs/moq-mux/src/container/mkv/import.rs @@ -85,7 +85,7 @@ enum TrackKind { impl TrackKind { /// The publisher priority for this kind of media, so audio isn't stuck behind a /// video backlog on a busy connection. - fn priority(&self) -> u8 { + fn priority(&self) -> i8 { match self { Self::Video => hang::catalog::PRIORITY.video, Self::Audio => hang::catalog::PRIORITY.audio, diff --git a/rs/moq-mux/src/container/test_util.rs b/rs/moq-mux/src/container/test_util.rs index b69610165d..fd612a2083 100644 --- a/rs/moq-mux/src/container/test_util.rs +++ b/rs/moq-mux/src/container/test_util.rs @@ -346,8 +346,16 @@ impl moq_net::transport::poll::Session for Gated { self.inner.poll_open_bi(cx, options) } - fn send_group(&mut self, priority: i64) -> Option { - self.inner.send_group(priority) + fn send_domain(&mut self) -> Option { + self.inner.send_domain() + } + + fn send_group( + &mut self, + domain: Option<&moq_net::transport::poll::Ungrouped>, + priority: i64, + ) -> Option { + self.inner.send_group(domain, priority) } fn send_datagram( diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 1da51bf77f..0c26a2e7f3 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -27,7 +27,7 @@ fn video_hint(init: &VideoInit, default_codec: Option /// as the moq-hang draft requires, so each frame timestamp lands on a media tick. fn track_info( reserved: &crate::catalog::Reserved, - priority: u8, + priority: i8, container: &hang::catalog::Container, ) -> Result { let info = reserved.track_info(priority); diff --git a/rs/moq-net/benches/priority.rs b/rs/moq-net/benches/priority.rs index 0bb25f0914..176f356bd3 100644 --- a/rs/moq-net/benches/priority.rs +++ b/rs/moq-net/benches/priority.rs @@ -21,7 +21,7 @@ const OVERFLOW_DEPTHS: [usize; 3] = [256, 1_024, 4_096]; fn filled(depth: usize) -> (PriorityQueue, Vec) { let queue = PriorityQueue::default(); let handles = (0..depth) - .map(|group| queue.insert(Priority::new(100, 0, group as u64))) + .map(|group| queue.insert(Priority::new(-28, 0, group as u64))) .collect(); (queue, handles) } @@ -33,7 +33,7 @@ fn bench_insert(c: &mut Criterion) { b.iter_batched_ref( || filled(depth), |(queue, handles)| { - handles.push(queue.insert(Priority::new(u8::MAX, 0, u64::MAX))); + handles.push(queue.insert(Priority::new(i8::MAX, 0, u64::MAX))); }, BatchSize::SmallInput, ); @@ -45,9 +45,9 @@ fn bench_insert(c: &mut Criterion) { fn overflow(depth: usize) -> Vec { let queue = PriorityQueue::default(); let mut handles: Vec<_> = (0..255) - .map(|group| queue.insert(Priority::new(200, 0, group))) + .map(|group| queue.insert(Priority::new(72, 0, group))) .collect(); - handles.extend((255..depth as u64).map(|group| queue.insert(Priority::new(100, 0, group)))); + handles.extend((255..depth as u64).map(|group| queue.insert(Priority::new(-28, 0, group)))); handles } diff --git a/rs/moq-net/benches/schedule.rs b/rs/moq-net/benches/schedule.rs index e02c647e6c..d049f4729f 100644 --- a/rs/moq-net/benches/schedule.rs +++ b/rs/moq-net/benches/schedule.rs @@ -49,7 +49,7 @@ const PACKET: usize = 1200; /// `cadence` ticks, numbered from `first` in steps of `step`. #[derive(Clone, Copy)] struct Track { - priority: u8, + priority: i8, rate: usize, cadence: u64, first: u64, @@ -57,7 +57,7 @@ struct Track { } impl Track { - const fn new(priority: u8, rate: usize, cadence: u64) -> Self { + const fn new(priority: i8, rate: usize, cadence: u64) -> Self { Self { priority, rate, @@ -349,7 +349,7 @@ impl Sim { } fn report(&self, workload: &str) { - let priorities: Vec = { + let priorities: Vec = { let mut p: Vec<_> = self.tracks.iter().map(|t| t.priority).collect(); p.sort(); p.dedup(); diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index 45c61b29b3..f95f94890b 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -719,13 +719,13 @@ fn bench_subscriber_preferences(c: &mut Criterion) { let held = |i: usize| { let i = i as u64; track::Subscription::default() - .with_priority((i % 255) as u8) + .with_priority(((i % 255) as i16 - 128) as i8) .with_max_delay(Duration::from_micros(i)) .with_groups(100 + i..200 + i) .with_order(track::Order::Ascending) }; let extreme = track::Subscription::default() - .with_priority(255) + .with_priority(i8::MAX) .with_max_delay(Duration::from_secs(10)); let mut group = c.benchmark_group("track_subscriber_preferences"); for steady in STEADY { diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index fa06045ed8..a431d231d1 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -19,6 +19,7 @@ pub struct Client { cost: Option, peer_hop: Option, limits: crate::session::Limits, + schedule: crate::session::Schedule, extensions: setup::Extensions, } @@ -66,6 +67,13 @@ impl Client { self } + /// Share bandwidth between the broadcasts this session serves as `schedule` says. + /// Defaults to [`Schedule::Strict`](crate::session::Schedule::Strict). + pub fn with_schedule(mut self, schedule: crate::session::Schedule) -> Self { + self.schedule = schedule; + self + } + /// Restrict which protocol versions to offer, in preference order. /// Defaults to every version this crate supports. pub fn with_versions(mut self, versions: Versions) -> Self { @@ -211,6 +219,7 @@ impl Client { runtime: runtime.clone(), client: true, limits: self.limits, + schedule: self.schedule, session: session.clone(), setup_stream: None, publish, @@ -428,6 +437,7 @@ impl Client { runtime: runtime.clone(), client: true, limits: self.limits, + schedule: self.schedule, session: session.clone(), setup_stream: Some(stream), publish: publish.clone(), @@ -636,7 +646,15 @@ mod tests { Poll::Pending } - fn send_group(&mut self, _: i64) -> Option { + fn send_domain(&mut self) -> Option { + None + } + + fn send_group( + &mut self, + _: Option<&crate::transport::poll::Ungrouped>, + _: i64, + ) -> Option { None } @@ -1040,7 +1058,15 @@ mod tests { }) } - fn send_group(&mut self, _: i64) -> Option { + fn send_domain(&mut self) -> Option { + None + } + + fn send_group( + &mut self, + _: Option<&crate::transport::poll::Ungrouped>, + _: i64, + ) -> Option { None } diff --git a/rs/moq-net/src/coding/writer.rs b/rs/moq-net/src/coding/writer.rs index 31a7f5d930..07895ba04d 100644 --- a/rs/moq-net/src/coding/writer.rs +++ b/rs/moq-net/src/coding/writer.rs @@ -219,6 +219,11 @@ impl Writer { self.stream.as_mut().unwrap().set_priority(priority); } + /// Move the stream into `group`, which belongs to the stream's session. + pub fn set_group(&mut self, group: &S::Group) { + self.stream.as_mut().unwrap().set_group(group); + } + /// Cast the writer to a different version, used during version negotiation. pub fn with_version(mut self, version: O) -> Writer { Writer { diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index 45fc6ddd16..9216fe2e93 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -1332,8 +1332,16 @@ impl crate::transport::poll::Session for Con .map(|r| r.map(AdapterRecv::Real).map_err(|_| crate::Error::Closed)) } - fn send_group(&mut self, priority: i64) -> Option<::Group> { - self.inner.send_group(priority) + fn send_domain(&mut self) -> Option> { + self.inner.send_domain() + } + + fn send_group( + &mut self, + domain: Option<&crate::transport::poll::DomainOf>, + priority: i64, + ) -> Option> { + self.inner.send_group(domain, priority) } fn send_datagram( diff --git a/rs/moq-net/src/ietf/fetch.rs b/rs/moq-net/src/ietf/fetch.rs index aff3d5cb22..5174df1920 100644 --- a/rs/moq-net/src/ietf/fetch.rs +++ b/rs/moq-net/src/ietf/fetch.rs @@ -311,7 +311,7 @@ impl Message for Fetch<'_> { Ok(Self { request_id, - subscriber_priority: subscriber_priority.unwrap_or(128), + subscriber_priority: subscriber_priority.unwrap_or(super::priority::ABSENT_WIRE), // No preference: the publisher picks the order. group_order: group_order.unwrap_or(GroupOrder::Any), fetch_type, diff --git a/rs/moq-net/src/ietf/group.rs b/rs/moq-net/src/ietf/group.rs index 5e27e089b3..7ddc17b82c 100644 --- a/rs/moq-net/src/ietf/group.rs +++ b/rs/moq-net/src/ietf/group.rs @@ -324,7 +324,7 @@ impl Decode for GroupHeader { let publisher_priority = if flags.has_priority { r.u8()? } else { - 128 // Default priority when absent + super::priority::ABSENT_WIRE }; let result = Self { diff --git a/rs/moq-net/src/ietf/priority.rs b/rs/moq-net/src/ietf/priority.rs index abc97c9ac0..7a3cf59617 100644 --- a/rs/moq-net/src/ietf/priority.rs +++ b/rs/moq-net/src/ietf/priority.rs @@ -1,13 +1,23 @@ //! Priority conversion between the IETF wire and the MoQ model. +//! +//! The wire is lower first, so `127 - priority` keeps one urgency with moq-lite's +//! `priority + 128`: the IETF byte is always `255 -` the lite byte. The unset priority 0 +//! goes out as 127, one step off the draft's usual 128, to keep that. -/// Convert an IETF subscriber priority into the model's higher-first priority. -pub(super) const fn from_wire(priority: u8) -> u8 { - u8::MAX - priority +/// The model priority an absent IETF priority stands for: the unset default. +pub(super) const ABSENT: i8 = 0; + +/// The wire byte of [`ABSENT`], for a message that must carry a priority it was not given. +pub(super) const ABSENT_WIRE: u8 = to_wire(ABSENT); + +/// Convert an IETF priority into the model's higher-first priority. +pub(super) const fn from_wire(priority: u8) -> i8 { + (127 - priority as i16) as i8 } -/// Convert the model's higher-first priority into an IETF subscriber priority. -pub(super) const fn to_wire(priority: u8) -> u8 { - u8::MAX - priority +/// Convert the model's higher-first priority into an IETF priority. +pub(super) const fn to_wire(priority: i8) -> u8 { + (127 - priority as i16) as u8 } #[cfg(test)] @@ -16,15 +26,33 @@ mod tests { #[test] fn ietf_priority_is_lower_first() { - assert_eq!(from_wire(0), u8::MAX); - assert_eq!(from_wire(u8::MAX), 0); - assert_eq!(to_wire(u8::MAX), 0); - assert_eq!(to_wire(0), u8::MAX); + assert_eq!(from_wire(0), i8::MAX); + assert_eq!(from_wire(u8::MAX), i8::MIN); + assert_eq!(to_wire(i8::MAX), 0); + assert_eq!(to_wire(i8::MIN), u8::MAX); assert!((0u8..u8::MAX).all(|priority| from_wire(priority) > from_wire(priority + 1))); } + /// The unset default goes out as 127, and an absent priority decodes to it. + #[test] + fn unset_is_127() { + assert_eq!(to_wire(0), 127); + assert_eq!(from_wire(127), 0); + assert_eq!(ABSENT, 0); + assert_eq!(ABSENT_WIRE, 127); + } + + /// One urgency on both wires: the IETF byte mirrors the moq-lite byte. + #[test] + fn mirrors_lite() { + for priority in i8::MIN..=i8::MAX { + assert_eq!(to_wire(priority), u8::MAX - crate::lite::priority::to_wire(priority)); + } + } + #[test] fn priority_round_trips() { - assert!((0u8..=u8::MAX).all(|priority| from_wire(to_wire(priority)) == priority)); + assert!((i8::MIN..=i8::MAX).all(|priority| from_wire(to_wire(priority)) == priority)); + assert!((0u8..=u8::MAX).all(|priority| to_wire(from_wire(priority)) == priority)); } } diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index 231fe8f168..b949293a8f 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -469,7 +469,7 @@ impl Message for PublishOk { ); let forward = forward.unwrap_or(true); - let subscriber_priority = subscriber_priority.unwrap_or(128); + let subscriber_priority = subscriber_priority.unwrap_or(super::priority::ABSENT_WIRE); let group_order = group_order.unwrap_or(GroupOrder::Descending); let filter = filter.unwrap_or(Filter::Unfiltered); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 4e7d6e9ef5..4bf2b14935 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -171,7 +171,7 @@ async fn read_fetch( sequence: u64, skip: u64, until: Option, - priority: u8, + priority: i8, ) -> Result { let info = track.query().await?; let cached = track.peek_group(sequence); @@ -2321,12 +2321,17 @@ where // Its own entry in the queue, or its own send group, so it competes with group // streams by its priority alone, its request id breaking ties in the queue. - // Nothing updates a FETCH's priority. + // Nothing updates a FETCH's priority, and it ranks before the track's info + // resolves, so the publisher's priority is the unset default. let ranking = Ranking { queue: self.priority.clone(), - group: self.session.clone().send_group(priority.into()), + group: self + .session + .clone() + .send_group(None, crate::priority::group(priority, 0)), request_id: msg.request_id, priority: kio::Producer::new(priority).consume(), + publisher: 0, order: Order::Descending, }; (track, start, end, false, ranking, gate) @@ -4127,7 +4132,7 @@ struct TrackServe { timescale: Option, children: kio::Tasks>, /// The subscriber priority, which every group stream follows while it is open. - priority: kio::Producer, + priority: kio::Producer, /// Where the group streams are ranked: the subscription's send group, or the session's /// queue once [`Self::ranked`] on a transport without groups. ranking: Ranking>, @@ -4169,12 +4174,14 @@ impl TrackServe { // objects on those drafts go out unstamped. let timescale = timescale.filter(|_| ietf::Properties::sends_timescale(version)); let priority = kio::Producer::new(track.subscription().priority); + let publisher = track.info().priority; let mut session = session; let ranking = Ranking { queue: PriorityQueue::default(), - group: session.send_group(track.subscription().priority.into()), + group: session.send_group(None, crate::priority::group(track.subscription().priority, publisher)), request_id, priority: priority.consume(), + publisher, order: track.subscription().order, }; @@ -4205,7 +4212,7 @@ impl TrackServe { } /// Move the subscriber priority, for the group streams already open as well as later ones. - fn set_priority(&mut self, priority: u8) -> Result<(), Error> { + fn set_priority(&mut self, priority: i8) -> Result<(), Error> { let mut subscription = self.track.subscription(); subscription.priority = priority; self.track.update(subscription)?; @@ -4215,7 +4222,7 @@ impl TrackServe { } // One move for the whole subscription; its streams keep their place within it. if let Some(group) = &mut self.ranking.group { - group.set_priority(priority.into()); + group.set_priority(crate::priority::group(priority, self.ranking.publisher)); } Ok(()) } @@ -4527,7 +4534,10 @@ struct Ranking { /// The request's send group, which replaces the queue on a transport that has them. group: Option, request_id: RequestId, - priority: kio::Consumer, + priority: kio::Consumer, + /// The publisher's priority for the track, breaking ties between requests at one + /// subscriber priority. + publisher: i8, /// The request's GROUP_ORDER, fixed for its life: no update carries it. order: Order, } @@ -4545,6 +4555,7 @@ impl Ranking { Rank::Queue(Queued { handle: self.queue.insert( Priority::new(applied, self.request_id.0, start.group) + .with_publisher(self.publisher) .with_order(self.order) .with_start(start.object), ), @@ -4650,7 +4661,7 @@ enum Rank { impl Rank { /// A group ranked only against itself, for driving one group machine alone. #[cfg(test)] - fn solo(priority: kio::Consumer) -> Self { + fn solo(priority: kio::Consumer) -> Self { Self::Queue(Queued::solo(priority)) } @@ -4681,9 +4692,9 @@ impl Rank { struct Queued { handle: PriorityHandle, /// The subscription's priority, followed while the stream is open. - priority: kio::Consumer, + priority: kio::Consumer, /// The priority last applied to [`Self::handle`]. - applied: u8, + applied: i8, /// The subscription's GROUP_ORDER, fixed for its life: no update carries it. order: Order, } @@ -4691,7 +4702,7 @@ struct Queued { impl Queued { /// A group ranked only against itself, for driving one group machine alone. #[cfg(test)] - fn solo(priority: kio::Consumer) -> Self { + fn solo(priority: kio::Consumer) -> Self { let applied = *priority.read(); Self { handle: PriorityQueue::default().insert(Priority::new(applied, 0, 0)), @@ -5255,7 +5266,7 @@ mod group_priority_test { None, ) .ranked(queue.clone()); - let _higher = queue.insert(Priority::new(u8::MAX, 0, 0)); + let _higher = queue.insert(Priority::new(i8::MAX, 0, 0)); serve.write_end_of_track(track::Position::group(0)).await.unwrap(); assert_eq!(log.priorities(), [254], "second, behind the higher priority"); } @@ -5439,7 +5450,7 @@ mod group_priority_test { } /// A group of `subscribe` ranked in `queue` at `priority`'s current value. - fn rank_in(queue: &PriorityQueue, priority: &kio::Producer, subscribe: u64) -> Rank { + fn rank_in(queue: &PriorityQueue, priority: &kio::Producer, subscribe: u64) -> Rank { let applied = *priority.read(); Rank::Queue(Queued { handle: queue.insert(Priority::new(applied, subscribe, 0)), @@ -5478,8 +5489,8 @@ mod group_priority_test { }; // Queue both before either opens, so each stream is handed its final rank. - let high = kio::Producer::new(200); - let low = kio::Producer::new(100); + let high = kio::Producer::new(72); + let low = kio::Producer::new(-28); let mut serves = [(&low, 1), (&high, 0)].map(|(priority, subscribe)| { GroupServe::new( SinkSession::new(log.clone()), @@ -5520,12 +5531,13 @@ mod group_priority_test { ); } - /// A track that never set a priority is the draft's usual publisher priority, 128, - /// not 255, the least urgent value a peer like moxygen would deprioritize. + /// A track that never set a priority goes out at the midpoint, 127, one step off the + /// draft's usual 128 so it mirrors moq-lite's 128, and not 255, the least urgent value a + /// peer like moxygen would deprioritize. #[moq_clock::test] async fn group_header_defaults_to_the_midpoint() { let header = serve_group_header(track::Info::timed()).await; - assert_eq!(header.publisher_priority, 128); + assert_eq!(header.publisher_priority, 127); } /// Drafts 14-16 cannot send TIMESCALE, so a group served with a track timescale still @@ -5702,7 +5714,7 @@ mod group_priority_test { let waiter = kio::Waiter::noop(); assert!(serve.poll(&waiter).is_pending()); - assert_eq!(log.groups(), [(0, 3)]); + assert_eq!(log.groups(), [(0, crate::priority::group(3, 0))]); assert_eq!( log.joins(), [(0, 0), (1, 0)], @@ -5713,7 +5725,11 @@ mod group_priority_test { serve.set_priority(9).unwrap(); assert!(serve.poll(&waiter).is_pending()); - assert_eq!(log.groups(), [(0, 3), (0, 9)], "one move for the subscription"); + assert_eq!( + log.groups(), + [(0, crate::priority::group(3, 0)), (0, crate::priority::group(9, 0))], + "one move for the subscription" + ); assert_eq!(log.orders().len(), 2, "the streams keep their place in the group"); } @@ -5868,11 +5884,11 @@ mod group_priority_test { /// `hang::catalog::PRIORITY` isn't reachable from `moq-net` (hang depends on it, not the /// other way round), so the two ranks it publishes audio and video at are spelled here. - fn hang_audio_priority() -> u8 { + fn hang_audio_priority() -> i8 { 80 } - fn hang_video_priority() -> u8 { + fn hang_video_priority() -> i8 { 60 } @@ -5986,11 +6002,11 @@ mod group_priority_test { let consumer = group.consume(); group.finish().unwrap(); - // Another subscription's group at 150 sits in the same queue, so each update + // Another subscription's group at 22 sits in the same queue, so each update // moves this stream across it. let queue = PriorityQueue::default(); - let _other = queue.insert(Priority::new(150, 1, 0)); - let priority = kio::Producer::new(200); + let _other = queue.insert(Priority::new(22, 1, 0)); + let priority = kio::Producer::new(72); let mut serve = GroupServe::new( session, ietf::GroupHeader { @@ -6013,11 +6029,11 @@ mod group_priority_test { ); assert_eq!(log.priorities(), vec![255]); - *priority.write().ok().unwrap() = 100; + *priority.write().ok().unwrap() = -28; assert!(futures::poll!(serving.as_mut()).is_pending()); assert_eq!(log.priorities(), vec![255, 254]); - *priority.write().ok().unwrap() = 175; + *priority.write().ok().unwrap() = 47; assert!(futures::poll!(serving.as_mut()).is_pending()); assert_eq!(log.priorities(), vec![255, 254, 255]); } @@ -6533,7 +6549,7 @@ mod serve_tests { None, )); assert!(futures::poll!(run.as_mut()).is_pending()); - assert_eq!(h.track.subscription().unwrap().priority, 245); + assert_eq!(h.track.subscription().unwrap().priority, 117); assert_eq!(occurrences(&session.log, &[0x07, 0, 1, 0]), 2); } @@ -6581,7 +6597,7 @@ mod serve_tests { for _ in 0..4 { assert!(futures::poll!(run.as_mut()).is_pending()); } - assert_eq!(h.track.subscription().unwrap().priority, 245); + assert_eq!(h.track.subscription().unwrap().priority, 117); let after = h.log.priorities(); assert_eq!(after.last(), Some(&255), "above the other subscription: {after:?}"); } @@ -6999,7 +7015,7 @@ mod serve_tests { None, )); assert!(futures::poll!(run.as_mut()).is_pending(), "{version}: update ended it"); - assert_eq!(h.track.subscription().unwrap().priority, 245, "{version}"); + assert_eq!(h.track.subscription().unwrap().priority, 117, "{version}"); // Drafts 15 and 16 answer with REQUEST_OK naming the update; draft 14 answers nothing. let answered = occurrences(&session.log, &[ietf::RequestOk::ID as u8, 0, 2, 10, 0]); @@ -8769,16 +8785,16 @@ mod serve_tests { } /// Subscriber priority 128 on the wire, which every request in these tests carries. - const PRIORITY: u8 = 127; + const PRIORITY: i8 = super::super::priority::from_wire(128); /// The streams a FETCH or fill competes with: one at a higher priority and one at a lower /// one, plus two at its own priority on either side of `request_id`. fn rival_streams(queue: &PriorityQueue, request_id: RequestId) -> [PriorityHandle; 4] { [ - queue.insert(Priority::new(200, 1, 0)), + queue.insert(Priority::new(72, 1, 0)), queue.insert(Priority::new(PRIORITY, request_id.0 - 1, 0)), queue.insert(Priority::new(PRIORITY, request_id.0 + 1, 0)), - queue.insert(Priority::new(50, 2, 0)), + queue.insert(Priority::new(-78, 2, 0)), ] } @@ -8797,6 +8813,7 @@ mod serve_tests { group: None, request_id: RequestId(REQUEST_ID), priority: priority.consume(), + publisher: 0, order: Order::Descending, }; let [higher, _older, _later, _lower] = rival_streams(&queue, ranking.request_id); @@ -8824,7 +8841,7 @@ mod serve_tests { let Ok(mut current) = priority.write() else { unreachable!("this test holds the producer") }; - *current = 255; + *current = i8::MAX; drop(current); assert!(futures::poll!(fill.as_mut()).is_pending()); assert_eq!( @@ -8852,6 +8869,7 @@ mod serve_tests { group: None, request_id: RequestId(REQUEST_ID), priority: kio::Producer::new(PRIORITY).consume(), + publisher: 0, order: Order::Descending, }; // The live stream, from object 1 on, queued first so the slab id alone would favor it. @@ -9658,7 +9676,7 @@ mod serve_tests { async fn fetch_ok_carries_the_track_properties() { let info = track::Info { max_age: Some(Duration::from_secs(5)), - priority: 200, + priority: 72, timescale: Some(crate::Timescale::MILLI), ..Default::default() }; diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index 3967895258..974175cf14 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -148,7 +148,7 @@ impl Message for Subscribe<'_> { // A zero cap still distinguishes a requested wait from an immediate miss. let rendezvous = rendezvous_timeout.is_some_and(|millis| millis > 0); - let subscriber_priority = subscriber_priority.unwrap_or(128); + let subscriber_priority = subscriber_priority.unwrap_or(super::priority::ABSENT_WIRE); let group_order = group_order.unwrap_or(GroupOrder::Descending); // An absent LOCATION_FILTER means the subscription is unfiltered. let filter = filter.unwrap_or(Filter::Unfiltered); @@ -448,7 +448,7 @@ impl Message for SubscribeUpdate { .encode(w, version)?; self.start_location.encode(w, version)?; w.varint(self.end_group)?; - w.u8(self.subscriber_priority.unwrap_or(128)); + w.u8(self.subscriber_priority.unwrap_or(super::priority::ABSENT_WIRE)); w.bool(self.forward.unwrap_or(true)); encode_params!(w, version, 0x03 => self.authorization_token.clone(), diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index d590a41101..cd92716e09 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -471,7 +471,7 @@ fn accepted_info(properties: &ietf::Properties) -> track::Info { let mut info = track::Info::default() .with_timescale(properties.timescale) .with_max_age(properties.max_cache_duration) - .with_priority(super::priority::from_wire(properties.priority.unwrap_or(128))) + .with_priority(properties.priority.map_or(super::priority::ABSENT, super::priority::from_wire)) // A publisher answering NEW_GROUP_REQUEST refreshes on any join with a delta to skip; // this session carries the request back to it. .with_refresh(properties.dynamic_groups.then_some(0)); @@ -7276,7 +7276,7 @@ mod tests { }); settle().await; - let _high = track.subscribe(track::Subscription::default().with_priority(200)); + let _high = track.subscribe(track::Subscription::default().with_priority(72)); settle().await; let writes = log.writes.lock().unwrap().clone(); @@ -7301,7 +7301,7 @@ mod tests { assert_eq!(update.subscription_request_id, subscribe); assert_eq!(update.start_location, ietf::Location { group: 5, object: 3 }); assert_eq!(update.end_group, 0, "still open ended"); - assert_eq!(update.subscriber_priority, Some(super::super::priority::to_wire(200))); + assert_eq!(update.subscriber_priority, Some(super::super::priority::to_wire(72))); assert_eq!(update.forward, Some(true)); } @@ -7517,7 +7517,7 @@ mod tests { .expect("the subscription is registered"); assert_eq!( track.producer.as_ref().unwrap().publisher_priority(), - 255 - priority.unwrap_or(128), + priority.map_or(0, super::super::priority::from_wire), "SUBSCRIBE_OK priority must be committed before alias registration" ); // The answer's largest is known before any group arrives. @@ -11465,7 +11465,7 @@ struct Update { /// Draft-14's mandatory Start Location, repeated as it stands; unused on later drafts. start_location: ietf::Location, /// The aggregate subscriber priority, in the model's higher-first terms. - priority: Option, + priority: Option, /// Subscriber age budget, in milliseconds. max_age: Option, /// NEW_GROUP_REQUEST. diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index c7999766c7..cac7d977cf 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -102,6 +102,8 @@ pub mod path; #[cfg(feature = "async")] mod priority; #[cfg(feature = "async")] +mod scheduler; +#[cfg(feature = "async")] mod recv; pub mod setup; #[cfg(feature = "async")] diff --git a/rs/moq-net/src/lite/fetch.rs b/rs/moq-net/src/lite/fetch.rs index 6472ed3108..df57064f94 100644 --- a/rs/moq-net/src/lite/fetch.rs +++ b/rs/moq-net/src/lite/fetch.rs @@ -17,7 +17,7 @@ pub struct Fetch<'a> { /// Lite07+ only. pub epoch: Option, pub track: Cow<'a, str>, - pub priority: u8, + pub priority: i8, /// Reader freshness budget. Lite07+ only; omission uses publisher retention alone. pub max_delay: Option, pub group: u64, @@ -40,7 +40,7 @@ impl Message for Fetch<'_> { let broadcast = Path::decode(r, version)?; let epoch = super::epoch::decode_epoch(r, version)?; let track = Cow::Owned(r.string()?); - let priority = r.u8()?; + let priority = super::priority::from_wire(r.u8()?); let max_delay = match version.has_fetch_max_delay() { true => r.varint_opt()?.map(std::time::Duration::from_millis), false => None, @@ -79,7 +79,7 @@ impl Message for Fetch<'_> { self.broadcast.encode(w, version)?; super::epoch::encode_epoch(w, version, self.epoch.as_ref())?; w.string(&self.track)?; - w.u8(self.priority); + w.u8(super::priority::to_wire(self.priority)); match version.has_fetch_max_delay() { true => w.varint_opt( self.max_delay diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index d80b29e8c7..2316017e0b 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod ietf; mod info; pub mod message; mod parameters; +pub(crate) mod priority; mod probe; #[cfg(feature = "async")] mod publisher; diff --git a/rs/moq-net/src/lite/priority.rs b/rs/moq-net/src/lite/priority.rs new file mode 100644 index 0000000000..0bf024df32 --- /dev/null +++ b/rs/moq-net/src/lite/priority.rs @@ -0,0 +1,34 @@ +//! Priority conversion between the moq-lite wire and the MoQ model. +//! +//! The wire carries `priority + 128` (the top bit flipped): one-to-one and order +//! preserving, so a byte means what it always meant and the unset priority 0 is byte 128. + +/// Convert a moq-lite priority byte into the model's priority. +pub(crate) const fn from_wire(priority: u8) -> i8 { + (priority ^ 0x80) as i8 +} + +/// Convert the model's priority into a moq-lite priority byte. +pub(crate) const fn to_wire(priority: i8) -> u8 { + (priority as u8) ^ 0x80 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lite_priority_is_offset() { + assert_eq!(to_wire(0), 128); + assert_eq!(to_wire(i8::MIN), 0); + assert_eq!(to_wire(i8::MAX), u8::MAX); + assert_eq!(from_wire(128), 0); + assert!((i8::MIN..i8::MAX).all(|priority| to_wire(priority) < to_wire(priority + 1))); + } + + #[test] + fn priority_round_trips() { + assert!((i8::MIN..=i8::MAX).all(|priority| from_wire(to_wire(priority)) == priority)); + assert!((0u8..=u8::MAX).all(|priority| to_wire(from_wire(priority)) == priority)); + } +} diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 2850cf0f48..78463303db 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -20,7 +20,8 @@ use crate::{ lite, priority::{Priority, PriorityHandle, PriorityQueue}, track::Order, - transport::poll::{GroupOf, SendGroup as _, SendOptions}, + scheduler::{Domain, Scheduler}, + transport::poll::{DomainOf, GroupOf, SendGroup as _, SendOptions}, }; use super::Version; @@ -53,6 +54,8 @@ pub(super) struct PublisherConfig { /// Subscriptions the peer may hold at once (`session::Limits::subscriptions`). pub subscriptions: crate::session::Slots, pub fetches: crate::session::Slots, + /// How the session shares bandwidth between the broadcasts it serves. + pub schedule: crate::session::Schedule, } /// Context shared by every control-stream child. @@ -75,6 +78,7 @@ struct Shared { // SETUP, so its declared id never changes for the session. serving: std::sync::OnceLock, priority: PriorityQueue, + scheduler: Scheduler>, version: Version, goaway: crate::goaway::Protocol, auth: crate::auth::Handle, @@ -184,8 +188,17 @@ impl Publisher { // origin we're consuming so it matches the local relay identity // across every session, required for cross-session loop detection. let self_origin = config.origin.hop(); - let accept = config.session.clone(); + let mut accept = config.session.clone(); let enforce = (config.client && config.version.has_auth()).then(Enforce::default); + // Strict rather than refused: a peer that reaches us over a transport without send + // domains still needs serving, just in one order. + let schedule = match config.schedule { + crate::session::Schedule::Broadcast if accept.send_domain().is_none() => { + tracing::debug!("the transport has no send domains, so the session schedules strictly"); + crate::session::Schedule::Strict + } + schedule => schedule, + }; Self { enforce, shared: Arc::new(Shared { @@ -198,6 +211,7 @@ impl Publisher { peer_hop: config.peer_hop, serving: std::sync::OnceLock::new(), priority: Default::default(), + scheduler: Scheduler::new(schedule), version: config.version, goaway: config.goaway, auth: config.auth, @@ -297,7 +311,7 @@ enum ControlState { }, Announce(AnnounceServe), Subscribe(RequestServe>), - Fetch(RequestServe), + Fetch(RequestServe>), TrackInfo(RequestServe), Probe(ProbeServe), Auth(AuthServe), @@ -1996,7 +2010,8 @@ impl Request for SubscribeServe { msg.epoch.as_ref() } - fn start(shared: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { + fn start(shared: &Shared, mut msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { + msg.priority = shared.scheduler.subscriber(msg.priority); let subscription = crate::track::Subscription { priority: msg.priority, max_delay: serving_max_delay(shared.version, msg.max_delay), @@ -2081,13 +2096,21 @@ impl Request for SubscribeServe { let steer_tx = kio::Producer::new(steer); let mut session = shared.session.clone(); - let group = session.send_group(msg.priority.into()); + let publisher = track.info().priority; + let domain = shared.scheduler.domain(&msg.broadcast, || session.send_domain()); + let group = session.send_group( + domain.as_deref().map(|domain| domain.get()), + crate::priority::group(msg.priority, publisher), + ); let sub = Subscription { session, id: msg.id, track_name: Arc::from(track.name()), priority: shared.priority.clone(), + scheduler: shared.scheduler.clone(), + publisher, group, + _domain: domain, steer: steer_tx.consume(), steer_seen: steer, version: shared.version, @@ -2140,7 +2163,7 @@ impl Request for SubscribeServe { // A state machine's enum is its storage: one transient instance per stream, so the // big variant is the working state, not padding held in bulk. #[allow(clippy::large_enum_variant)] -enum FetchServe { +enum FetchServe { /// Waiting for the fetched group. Fetch { msg: lite::Fetch<'static>, @@ -2159,13 +2182,17 @@ enum FetchServe { batch_pos: usize, /// The `ietf` extension's object records, when negotiated. records: Option, + /// The response's send group in its broadcast's domain, on a session scheduled per + /// broadcast, so a fetch shares its broadcast's bytes rather than going ahead of + /// every broadcast's. `None` leaves it with the ungrouped streams. + _group: Option<(GroupOf, Domain>)>, }, } /// A batch of frames with their relay metadata, which the `ietf` extension forwards. type Objects = arrayvec::ArrayVec; -impl Request for FetchServe { +impl Request for FetchServe { const KIND: &'static str = "fetch"; const FETCH: bool = true; // The response carries no header, so a run cut short would read as whole. @@ -2183,7 +2210,8 @@ impl Request for FetchServe { msg.epoch.as_ref() } - fn start(_: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { + fn start(shared: &Shared, mut msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { + msg.priority = shared.scheduler.subscriber(msg.priority); let fetching = broadcast .track(&msg.track)? .fetch_group( @@ -2232,6 +2260,17 @@ impl Request for FetchServe { let capped = msg.end_frame.is_some(); let records = lite::ietf::Records::start(writer, ietf, &group, capped, timescale, shared.version)?; + let mut session = shared.session.clone(); + let placed = shared + .scheduler + .domain(&msg.broadcast, || session.send_domain()) + .and_then(|domain| { + let priority = crate::priority::group(msg.priority, group.track_priority()); + let group = session.send_group(Some(domain.get()), priority)?; + writer.set_group(&group); + Some((group, domain)) + }); + *self = Self::Serve { group, timescale, @@ -2241,6 +2280,7 @@ impl Request for FetchServe { batch: Box::default(), batch_pos: 0, records, + _group: placed, }; } Self::Serve { @@ -2252,6 +2292,7 @@ impl Request for FetchServe { batch, batch_pos, records, + .. } => { let mut cx = waiter.context(); loop { @@ -3488,7 +3529,7 @@ impl From<&lite::SubscribeUpdate> for Bounds { /// The part of each group's rank a SUBSCRIBE_UPDATE can move. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct Steer { - priority: u8, + priority: i8, order: Order, } @@ -3502,9 +3543,15 @@ struct Subscription { id: u64, track_name: Arc, priority: PriorityQueue, + /// Maps the subscriber's priority updates onto the session's schedule. + scheduler: Scheduler>, + /// The publisher's priority for the track, which breaks subscriber priority ties. + publisher: i8, /// The subscription's send group, ranking it against the session's others, or `None` /// on a transport without groups, where `priority` ranks every group stream instead. group: Option>, + /// The broadcast's send domain, on a session scheduled per broadcast. + _domain: Option>>, steer: kio::Consumer, /// Last steer observed by this clone, so a change only fires once. steer_seen: Steer, @@ -3726,15 +3773,16 @@ impl TrackRun { { self.track.join(); } + let priority = self.ctx.scheduler.subscriber(upd.priority); if let Ok(mut value) = self.steer_tx.write() { *value = Steer { - priority: upd.priority, + priority, order: upd.order, }; } // One move for the whole subscription; its streams keep their order within it. if let Some(group) = &mut self.ctx.group { - group.set_priority(upd.priority.into()); + group.set_priority(crate::priority::group(priority, self.ctx.publisher)); } // Feed the full update into the model subscriber so the producer's // aggregate reflects it (and a relay re-forwards it upstream). @@ -3743,7 +3791,7 @@ impl TrackRun { let floored = requested.is_some_and(|group| group > 0); let bounds = Bounds::from(&upd); let _ = self.track.update(crate::track::Subscription { - priority: upd.priority, + priority, max_delay: serving_max_delay(self.ctx.version, upd.max_delay), order: upd.order, ..bounds.positions() @@ -4089,7 +4137,11 @@ impl TrackRun { None => Rank::Queue( self.ctx .priority - .insert(Priority::new(steer.priority, self.ctx.id, sequence).with_order(steer.order)), + .insert( + Priority::new(steer.priority, self.ctx.id, sequence) + .with_publisher(self.ctx.publisher) + .with_order(steer.order), + ), ), }; self.children.push(GroupServe::new( @@ -4299,6 +4351,7 @@ impl GroupServe { batch, batch_pos, records, + .. } => { let mut cx = waiter.context(); @@ -4635,7 +4688,10 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + scheduler: Scheduler::new(Default::default()), + publisher: 0, group: None, + _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -4681,7 +4737,10 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + scheduler: Scheduler::new(Default::default()), + publisher: 0, group: None, + _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -4731,7 +4790,10 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + scheduler: Scheduler::new(Default::default()), + publisher: 0, group: None, + _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -4781,7 +4843,10 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + scheduler: Scheduler::new(Default::default()), + publisher: 0, group: None, + _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -4849,7 +4914,10 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + scheduler: Scheduler::new(Default::default()), + publisher: 0, group: None, + _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -4926,7 +4994,10 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + scheduler: Scheduler::new(Default::default()), + publisher: 0, group: None, + _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -4975,7 +5046,10 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + scheduler: Scheduler::new(Default::default()), + publisher: 0, group: None, + _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -5023,7 +5097,10 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + scheduler: Scheduler::new(Default::default()), + publisher: 0, group: None, + _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -5083,7 +5160,10 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + scheduler: Scheduler::new(Default::default()), + publisher: 0, group: None, + _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version, @@ -5161,7 +5241,7 @@ mod serve_group_test { let mut session = SinkSession::new(Log::default()).with_groups().with_unacked_fin(); let log = session.log.clone(); let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); - run.ctx.group = crate::transport::poll::Session::send_group(&mut session, 3); + run.ctx.group = crate::transport::poll::Session::send_group(&mut session, None, 3); write_group(&mut track, 0, 0); write_group(&mut track, 1, 1); @@ -5192,7 +5272,11 @@ mod serve_group_test { let mut poll = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); assert!(futures::poll!(poll.as_mut()).is_pending()); } - assert_eq!(log.groups(), [(0, 3), (0, 7)], "one move for the subscription"); + assert_eq!( + log.groups(), + [(0, 3), (0, crate::priority::group(7, 0))], + "one move for the subscription" + ); let mut flipped = log.orders()[2..].to_vec(); flipped.sort(); assert_eq!(flipped, [(0, 1), (1, -1)], "older groups first"); @@ -5214,7 +5298,7 @@ mod serve_group_test { .with_unacked_fin(); let log = session.log.clone(); let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); - run.ctx.group = crate::transport::poll::Session::send_group(&mut session, 3); + run.ctx.group = crate::transport::poll::Session::send_group(&mut session, None, 3); write_group(&mut track, 0, 0); let sequence = track @@ -5243,7 +5327,7 @@ mod serve_group_test { let mut session = SinkSession::new(Log::default()).with_groups().with_datagrams(1200); let log = session.log.clone(); let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); - run.ctx.group = crate::transport::poll::Session::send_group(&mut session, 3); + run.ctx.group = crate::transport::poll::Session::send_group(&mut session, None, 3); let first = track .append_datagram(Timestamp::from_millis(1).unwrap(), b"a".as_slice()) @@ -6214,7 +6298,10 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + scheduler: Scheduler::new(Default::default()), + publisher: 0, group: None, + _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite07, @@ -6467,6 +6554,7 @@ mod tests { use super::*; use crate::lite::test_transport::{Close, ScriptedSession, SinkSend, SinkSession}; use crate::model::ProduceTest; + use crate::{broadcast, coding::Encode}; use futures::FutureExt; /// A peer that declares no origin in its SETUP is split-horizoned by the identity @@ -6514,6 +6602,7 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), + schedule: Default::default(), estimate: kio::Producer::default().weak(), }); @@ -6685,6 +6774,7 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), + schedule: Default::default(), estimate: estimate.weak(), }); ProbeServe::new(publisher.shared.clone(), publisher.runtime.clone(), stream) @@ -6984,6 +7074,7 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), + schedule: Default::default(), estimate: kio::Producer::default().weak(), }); @@ -7080,7 +7171,7 @@ mod tests { start_frame: 0, end_frame: None, }; - leave_request::(version, &fetch, wait, close).await; + leave_request::>(version, &fetch, wait, close).await; } } } @@ -7114,6 +7205,7 @@ mod tests { peer_hop: None, subscriptions: Default::default(), fetches: Default::default(), + schedule: Default::default(), auth: crate::auth::Handle::new(false), peer_grant: Default::default(), client: false, @@ -7206,6 +7298,7 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), + schedule: Default::default(), estimate: kio::Producer::default().weak(), }); @@ -7322,6 +7415,7 @@ mod tests { peer_hop: None, subscriptions: Default::default(), fetches: Default::default(), + schedule: Default::default(), auth: crate::auth::Handle::new(false), peer_grant: Default::default(), client: false, @@ -7483,6 +7577,7 @@ mod tests { peer_hop: None, subscriptions: crate::session::Slots::new(0), fetches: Default::default(), + schedule: Default::default(), auth: crate::auth::Handle::new(false), peer_grant: Default::default(), client: false, @@ -7527,6 +7622,7 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), + schedule: Default::default(), estimate: kio::Producer::default().weak(), }); let mut script = Vec::new(); @@ -7552,7 +7648,7 @@ mod tests { writer: Writer::new(send, VERSION), reader: crate::coding::Reader::new(recv, VERSION), }; - let mut serve = RequestServe::<_, FetchServe>::new(publisher.shared.clone(), stream); + let mut serve = RequestServe::<_, FetchServe<_>>::new(publisher.shared.clone(), stream); assert!(!drive(&mut serve).await, "the open group holds the response"); // The track runs a full second past the group's successor. @@ -7594,6 +7690,7 @@ mod tests { client: false, subscriptions: crate::session::Slots::new(0), fetches: crate::session::Slots::new(2), + schedule: Default::default(), estimate: kio::Producer::default().weak(), }); let mut script = Vec::new(); @@ -7622,7 +7719,7 @@ mod tests { writer: Writer::new(send, VERSION), reader: crate::coding::Reader::new(recv, VERSION), }; - let mut request = RequestServe::<_, FetchServe>::new(publisher.shared.clone(), stream); + let mut request = RequestServe::<_, FetchServe<_>>::new(publisher.shared.clone(), stream); if count < 2 { assert!(!drive(&mut request).await); held.push(request); @@ -7905,4 +8002,239 @@ mod tests { } } } + + /// A publisher on a session with send domains and groups, scheduled as `schedule`. + fn grouped_publisher(origin: origin::Consumer, schedule: crate::session::Schedule) -> Publisher { + scheduled_publisher(origin, schedule, ScriptedSession::new(Vec::new()).with_groups()) + } + + /// A publisher on `session`, scheduled as `schedule`. + fn scheduled_publisher( + origin: origin::Consumer, + schedule: crate::session::Schedule, + session: ScriptedSession, + ) -> Publisher { + let peer_setup = crate::lite::PeerSetup::new(false, crate::transport::Binding::InBand); + peer_setup.set(crate::lite::Setup::default()); + let (_, goaway) = crate::goaway::Handle::new(true); + Publisher::new(PublisherConfig { + runtime: crate::time::sim(), + session, + origin, + version: Version::Lite07, + peer_setup, + goaway, + peer_hop: None, + auth: crate::auth::Handle::new(false), + peer_grant: Default::default(), + client: false, + subscriptions: Default::default(), + fetches: Default::default(), + schedule, + estimate: kio::Producer::default().weak(), + }) + } + + /// Serve a request stream whose peer sends `request`, returning that peer. + async fn serve_request>( + publisher: &Publisher, + request: &impl Encode, + ) -> (ScriptedSession, RequestServe) { + let version = Version::Lite07; + let mut script = Vec::new(); + request + .encode(&mut crate::coding::Encoder::new(&mut script, version.into()), version) + .unwrap(); + let mut session = ScriptedSession::new(script); + let (send, recv) = futures::future::poll_fn(|cx| { + ::poll_open_bi(&mut session, cx, Default::default()) + }) + .await + .unwrap(); + let stream = Stream:: { + writer: Writer::new(send, version), + reader: crate::coding::Reader::new(recv, version), + }; + (session, RequestServe::new(publisher.shared.clone(), stream)) + } + + type Subscribed = ( + ScriptedSession, + RequestServe>, + track::Producer, + ); + + /// Subscribe to `broadcast`'s `track` at the subscriber `priority`, resolving the track + /// with `info`, until the subscription runs. + async fn subscribe_at( + publisher: &Publisher, + dynamic: &mut broadcast::Dynamic, + broadcast: &'static str, + track: &'static str, + priority: i8, + info: track::Info, + ) -> Subscribed { + let msg = lite::Subscribe { + order: crate::track::Order::Descending, + epoch: None, + id: 0, + broadcast: crate::Path::new(broadcast), + track: track.into(), + priority, + max_delay: Duration::ZERO, + start_group: None, + end_group: None, + start_frame: 0, + end_frame: None, + }; + let (session, mut serve) = serve_request::>(publisher, &msg).await; + assert!(!drive(&mut serve).await); + let track = dynamic + .requested_track() + .now_or_never() + .expect("track requested") + .unwrap() + .accept(info); + assert!(!drive(&mut serve).await, "the subscription ended"); + assert!( + matches!(serve.state, RequestState::Serve(SubscribeServe::Run(_))), + "the subscription is not running" + ); + (session, serve, track) + } + + /// Two announced broadcasts, `a` and `b`, with their track requests. + fn two_broadcasts() -> ( + origin::Producer, + [(broadcast::Producer, broadcast::Dynamic); 2], + ) { + let origin = crate::origin::Config::new(Hop::new(1).unwrap()).produce(); + let broadcasts = ["a", "b"].map(|name| { + let broadcast = origin.create_broadcast(name).unwrap(); + let dynamic = broadcast.dynamic(); + broadcast.announce(Default::default()).unwrap(); + (broadcast, dynamic) + }); + (origin, broadcasts) + } + + /// Scheduled per broadcast, a broadcast's subscriptions share one send domain, so the + /// transport splits bytes evenly between broadcasts however many tracks or subscribers + /// each has. Inside it the publisher's track priority alone orders the groups: the + /// peer's subscriber priority, at either extreme or updated, moves nothing, and is not + /// fed upstream. + #[moq_clock::test] + async fn per_broadcast_schedule_ranks_by_the_publisher_alone() { + let (origin, [(_a, mut a), (_b, mut b)]) = two_broadcasts(); + let publisher = grouped_publisher(origin.consume(), crate::session::Schedule::Broadcast); + let log = publisher.shared.session.log.clone(); + let video = || track::Info::timed().with_priority(60); + let audio = || track::Info::timed().with_priority(80); + + let mut a_video = subscribe_at(&publisher, &mut a, "a", "video", i8::MAX, video()).await; + let _a_audio = subscribe_at(&publisher, &mut a, "a", "audio", i8::MIN, audio()).await; + let _b_video = subscribe_at(&publisher, &mut b, "b", "video", 0, video()).await; + assert_eq!(log.groups(), [(0, 60), (1, 80), (2, 60)]); + let domains = log.domains(); + assert!( + domains[0].is_some() && domains[1] == domains[0] && domains[2].is_some() && domains[2] != domains[0], + "not one domain per broadcast: {domains:?}" + ); + + let update = lite::SubscribeUpdate { + wanted_group: None, + order: crate::track::Order::Descending, + priority: i8::MIN, + max_delay: Duration::ZERO, + start_group: None, + end_group: None, + start_frame: 0, + end_frame: None, + }; + a_video.0.push(&update.encode_bytes(Version::Lite07).unwrap()); + assert!(!drive(&mut a_video.1).await, "the update ended the subscription"); + assert!( + log.groups().iter().all(|&(group, priority)| group != 0 || priority == 60), + "the update moved the group: {:?}", + log.groups() + ); + let RequestState::Serve(SubscribeServe::Run(run)) = &a_video.1.state else { + panic!("the subscription is not running"); + }; + assert_eq!(run.steer_tx.read().priority, 0, "the subscriber priority was kept"); + } + + /// The default schedule keeps every subscription in the session's default domain, + /// ranked by the peer's subscriber priority first and the publisher's after. + #[moq_clock::test] + async fn strict_schedule_ranks_by_the_subscriber_first() { + let (origin, [(_a, mut a), (_b, mut b)]) = two_broadcasts(); + let publisher = grouped_publisher(origin.consume(), crate::session::Schedule::Strict); + let log = publisher.shared.session.log.clone(); + + let _a = subscribe_at(&publisher, &mut a, "a", "video", i8::MAX, track::Info::timed().with_priority(60)).await; + let _b = subscribe_at(&publisher, &mut b, "b", "audio", i8::MIN, track::Info::timed().with_priority(80)).await; + assert_eq!( + log.groups(), + [ + (0, crate::priority::group(i8::MAX, 60)), + (1, crate::priority::group(i8::MIN, 80)) + ] + ); + assert_eq!(log.domains(), [None, None]); + } + + /// Asked to schedule per broadcast over a transport without send domains, the publisher + /// serves strictly rather than refusing the peer, so the subscriber priority still ranks. + #[moq_clock::test] + async fn per_broadcast_schedule_without_send_domains_is_strict() { + let (origin, [(_a, mut a), _]) = two_broadcasts(); + let publisher = scheduled_publisher( + origin.consume(), + crate::session::Schedule::Broadcast, + ScriptedSession::new(Vec::new()), + ); + let (_session, serve, _track) = + subscribe_at(&publisher, &mut a, "a", "video", i8::MAX, track::Info::timed()).await; + let RequestState::Serve(SubscribeServe::Run(run)) = &serve.state else { + panic!("the subscription is not running"); + }; + assert_eq!(run.steer_tx.read().priority, i8::MAX, "the subscriber priority was dropped"); + } + + /// Scheduled per broadcast, a FETCH response joins its broadcast's domain at the + /// track's publisher priority, so a backfill shares its broadcast's bytes rather than + /// going ahead of every broadcast's. + #[moq_clock::test] + async fn per_broadcast_schedule_places_a_fetch_in_its_broadcast() { + let (origin, [(_a, mut a), _]) = two_broadcasts(); + let publisher = grouped_publisher(origin.consume(), crate::session::Schedule::Broadcast); + let log = publisher.shared.session.log.clone(); + + let (_session, _serve, track) = + subscribe_at(&publisher, &mut a, "a", "video", 0, track::Info::timed().with_priority(60)).await; + let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"x".as_slice()).unwrap(); + group.finish().unwrap(); + + let fetch = lite::Fetch { + epoch: None, + broadcast: crate::Path::new("a"), + track: "video".into(), + priority: i8::MAX, + max_delay: None, + group: 0, + start_frame: 0, + end_frame: None, + }; + let (session, mut serve) = serve_request::>(&publisher, &fetch).await; + drive(&mut serve).await; + assert_eq!(log.groups(), [(0, 60), (1, 60)]); + let domains = log.domains(); + assert!( + domains[0].is_some() && domains[1] == domains[0], + "the fetch left its broadcast's domain: {domains:?}" + ); + assert_eq!(session.log.joins(), [(0, 1)], "the response never joined its group"); + } } diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 3e671e2099..698d2b69ee 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -189,6 +189,8 @@ pub struct Config { pub auth: crate::auth::Handle, /// What the peer may make this session hold. pub limits: crate::session::Limits, + /// How the session shares bandwidth between the broadcasts it serves. + pub schedule: crate::session::Schedule, } /// Start a lite session. @@ -211,8 +213,10 @@ where peer_setup, auth, limits, + schedule, } = config; + let recv_bw = bandwidth::Producer::new(); let recv_bw_consumer = match version { @@ -308,6 +312,7 @@ where client, subscriptions, fetches: crate::session::Slots::new(limits.fetches), + schedule, }); let mut subscriber = Subscriber::new(SubscriberConfig { runtime: runtime.clone(), @@ -914,6 +919,7 @@ mod tests { runtime: crate::time::sim(), client: true, limits: Default::default(), + schedule: Default::default(), session: transport, setup_stream: None, publish: None, @@ -946,6 +952,7 @@ mod tests { runtime: crate::time::sim(), client: false, limits: Default::default(), + schedule: Default::default(), session: transport, setup_stream: None, publish: None, @@ -1032,6 +1039,7 @@ mod tests { runtime: crate::time::sim(), client: true, limits: Default::default(), + schedule: Default::default(), session: transport, setup_stream: None, publish: None, @@ -1110,6 +1118,7 @@ mod tests { client: false, auth: crate::auth::Handle::new(version.has_auth()), limits: Default::default(), + schedule: Default::default(), }) .unwrap(); let _ = started.driver.poll(&kio::Waiter::noop()); @@ -1164,6 +1173,7 @@ mod tests { client: false, auth: crate::auth::Handle::new(version.has_auth()), limits: Default::default(), + schedule: Default::default(), }) .unwrap(); let _ = started.driver.poll(&kio::Waiter::noop()); @@ -1196,6 +1206,7 @@ mod tests { peer_setup, auth: crate::auth::Handle::new(version.has_auth()), limits: Default::default(), + schedule: Default::default(), }) .unwrap() } diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index 570edad4f6..0f75037520 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -19,7 +19,7 @@ pub struct Subscribe<'a> { /// Lite07+ only. pub epoch: Option, pub track: Cow<'a, str>, - pub priority: u8, + pub priority: i8, pub max_delay: std::time::Duration, /// The minimum group to deliver (a floor). On lite-06 the wire carries the raw /// sequence and `None` is interchangeable with `Some(0)`: a floor of 0 constrains @@ -56,7 +56,7 @@ impl Message for Subscribe<'_> { let broadcast = Path::decode(r, version)?; let epoch = super::epoch::decode_epoch(r, version)?; let track = Cow::Owned(r.string()?); - let priority = r.u8()?; + let priority = super::priority::from_wire(r.u8()?); let (order, max_delay, start_group, end_group) = match version { Version::Lite01 | Version::Lite02 => (Order::Descending, std::time::Duration::ZERO, None, None), @@ -92,7 +92,7 @@ impl Message for Subscribe<'_> { self.broadcast.encode(w, version)?; super::epoch::encode_epoch(w, version, self.epoch.as_ref())?; w.string(&self.track)?; - w.u8(self.priority); + w.u8(super::priority::to_wire(self.priority)); match version { Version::Lite01 | Version::Lite02 => {} @@ -259,7 +259,7 @@ fn encode_frame_bounds( /// to [`super::TrackInfo`]. #[derive(Clone, Debug)] pub struct SubscribeOk { - pub priority: u8, + pub priority: i8, pub max_delay: std::time::Duration, pub start_group: Option, pub end_group: Option, @@ -269,13 +269,13 @@ impl Message for SubscribeOk { fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { match version { Version::Lite01 => { - w.u8(self.priority); + w.u8(super::priority::to_wire(self.priority)); } Version::Lite02 => {} // Lite05+ never sends SUBSCRIBE_OK, but keep the field layout matching // Lite03/04 so a stray future use stays well-formed. _ => { - w.u8(self.priority); + w.u8(super::priority::to_wire(self.priority)); pad_group_order(w, version)?; w.varint(u64::try_from(self.max_delay.as_millis()).map_err(|_| EncodeError::BoundsExceeded)?)?; w.varint_opt(self.start_group)?; @@ -289,7 +289,7 @@ impl Message for SubscribeOk { fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { match version { Version::Lite01 => Ok(Self { - priority: r.u8()?, + priority: super::priority::from_wire(r.u8()?), max_delay: std::time::Duration::ZERO, start_group: None, end_group: None, @@ -301,7 +301,7 @@ impl Message for SubscribeOk { end_group: None, }), _ => { - let priority = r.u8()?; + let priority = super::priority::from_wire(r.u8()?); skip_group_order(r, version)?; let max_delay = std::time::Duration::from_millis(r.varint()?); let start_group = r.varint_opt()?; @@ -403,7 +403,7 @@ impl Message for SubscribeEnd { pub struct SubscribeUpdate { /// A join requesting this group or later, judged against the local refresh ratio. pub wanted_group: Option, - pub priority: u8, + pub priority: i8, pub max_delay: std::time::Duration, pub start_group: Option, pub end_group: Option, @@ -424,7 +424,7 @@ impl Message for SubscribeUpdate { _ => {} } - let priority = r.u8()?; + let priority = super::priority::from_wire(r.u8()?); let order = decode_order(r, version)?; let max_delay = std::time::Duration::from_millis(r.varint()?); let start_group = decode_start_group(r, version)?; @@ -461,7 +461,7 @@ impl Message for SubscribeUpdate { _ => {} } - w.u8(self.priority); + w.u8(super::priority::to_wire(self.priority)); encode_order(w, version, self.order)?; w.varint(u64::try_from(self.max_delay.as_millis()).map_err(|_| EncodeError::BoundsExceeded)?)?; diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 825dcbe86e..36dda3ba24 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -5183,7 +5183,7 @@ struct SubStream { /// downstream aggregate changes. max_delay: Duration, start: Option, - priority: u8, + priority: i8, order: crate::track::Order, /// The start the SUBSCRIBE itself carried, fixed for the stream's life. A /// SUBSCRIBE_START describes this demand and no fresh one follows an update, diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index e36de468f1..aea1468f9e 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -60,6 +60,10 @@ pub struct Log { /// Hands each send group its id. group_ids: Arc, groups: Arc>>, + /// Hands each send domain its id. + domain_ids: Arc, + /// The domain each send group opened in, by group id. + domains: Arc>>>, joins: Arc>>, orders: Arc>>, } @@ -119,6 +123,12 @@ impl Log { self.groups.lock().unwrap().clone() } + /// The send domain each send group opened in, indexed by group id: `None` for the + /// session's default domain. + pub fn domains(&self) -> Vec> { + self.domains.lock().unwrap().clone() + } + /// Every send group a stream joined, as (stream, group) in call order. pub fn joins(&self) -> Vec<(usize, usize)> { self.joins.lock().unwrap().clone() @@ -205,14 +215,36 @@ pub struct SinkGroup { } impl SinkGroup { - fn open(log: Log, priority: i64) -> Self { + fn open(log: Log, domain: Option, priority: i64) -> Self { let id = log.group_ids.fetch_add(1, Ordering::Relaxed); log.groups.lock().unwrap().push((id, priority)); + let mut domains = log.domains.lock().unwrap(); + if domains.len() <= id { + domains.resize(id + 1, None); + } + domains[id] = domain; + drop(domains); Self { id, log } } } +/// A send domain of a [`SinkSession::with_groups`], identified by its opening order. +#[derive(Clone)] +pub struct SinkDomain { + id: usize, +} + +impl SinkDomain { + fn open(log: &Log) -> Self { + Self { + id: log.domain_ids.fetch_add(1, Ordering::Relaxed), + } + } +} + impl poll::SendGroup for SinkGroup { + type Domain = SinkDomain; + fn set_priority(&mut self, priority: i64) { self.log.groups.lock().unwrap().push((self.id, priority)); } @@ -506,7 +538,11 @@ impl poll::Session for DeadStreamSession { Poll::Ready(Ok(SinkSend::new(self.log.clone()).opened(options))) } - fn send_group(&mut self, _: i64) -> Option { + fn send_domain(&mut self) -> Option { + None + } + + fn send_group(&mut self, _: Option<&SinkDomain>, _: i64) -> Option { None } @@ -727,8 +763,13 @@ impl poll::Session for SinkSession { Poll::Ready(Ok(send.opened(options))) } - fn send_group(&mut self, priority: i64) -> Option { - self.groups.then(|| SinkGroup::open(self.log.clone(), priority)) + fn send_domain(&mut self) -> Option { + self.groups.then(|| SinkDomain::open(&self.log)) + } + + fn send_group(&mut self, domain: Option<&SinkDomain>, priority: i64) -> Option { + self.groups + .then(|| SinkGroup::open(self.log.clone(), domain.map(|domain| domain.id), priority)) } fn send_datagram( @@ -882,6 +923,8 @@ pub struct ScriptedSession { /// Set by [`Self::with_unacked_fin`]. unacked_fin: bool, datagram_size: usize, + /// Set by [`Self::with_groups`]. + groups: bool, } impl ScriptedSession { @@ -908,9 +951,16 @@ impl ScriptedSession { incoming_bidis: Arc::new(Mutex::new(std::collections::VecDeque::new())), unacked_fin: false, datagram_size: 0, + groups: false, } } + /// Open send domains and groups, as native QUIC does, recording them in the log. + pub fn with_groups(mut self) -> Self { + self.groups = true; + self + } + /// Never acknowledge an opened stream's FIN, like a peer that stopped reading. pub fn with_unacked_fin(mut self) -> Self { self.unacked_fin = true; @@ -1065,8 +1115,13 @@ impl poll::Session for ScriptedSession { Poll::Ready(Ok(SinkSend::new(self.log.clone()).opened(options))) } - fn send_group(&mut self, _: i64) -> Option { - None + fn send_domain(&mut self) -> Option { + self.groups.then(|| SinkDomain::open(&self.log)) + } + + fn send_group(&mut self, domain: Option<&SinkDomain>, priority: i64) -> Option { + self.groups + .then(|| SinkGroup::open(self.log.clone(), domain.map(|domain| domain.id), priority)) } fn send_datagram( diff --git a/rs/moq-net/src/lite/track.rs b/rs/moq-net/src/lite/track.rs index a2a5f5ca5f..045535266c 100644 --- a/rs/moq-net/src/lite/track.rs +++ b/rs/moq-net/src/lite/track.rs @@ -62,7 +62,7 @@ pub struct TrackInfo { /// Refresh byte ratio, advertised on lite-07 only. pub refresh: Option, /// The publisher's tie-break priority for this track. - pub priority: u8, + pub priority: i8, /// Publisher Max Age: an upper bound on how long the publisher caches a /// non-latest group past the arrival of a newer one. Encoded as milliseconds. pub max_age: Option, @@ -78,7 +78,7 @@ impl Message for TrackInfo { return Err(DecodeError::Version); } - let priority = r.u8()?; + let priority = super::priority::from_wire(r.u8()?); super::subscribe::skip_group_order(r, version)?; let encoded = r.varint()?; let max_age = match version { @@ -114,7 +114,7 @@ impl Message for TrackInfo { return Err(EncodeError::Version); } - w.u8(self.priority); + w.u8(super::priority::to_wire(self.priority)); super::subscribe::pad_group_order(w, version)?; let encoded = match (version, self.max_age) { (Version::Lite05 | Version::Lite06, None) => LEGACY_UNLIMITED, @@ -252,7 +252,7 @@ mod test { assert_eq!( buf, [ - 0x0c, 0x7f, 0x00, 0xc0, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x43, 0xe8 + 0x0c, 0x80, 0x00, 0xc0, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x43, 0xe8 ] ); } @@ -309,12 +309,17 @@ mod test { fn track_info_roundtrips_varint_and_priority_bounds() { let info = TrackInfo { refresh: None, - priority: 255, + priority: i8::MIN, max_age: None, timescale: Some(Timescale::new((1u64 << 62) - 1).unwrap()), }; let got = info_roundtrip(Version::Lite05, &info); - assert_eq!(got.priority, 255); + assert_eq!(got.priority, i8::MIN); + let info = TrackInfo { + priority: i8::MAX, + ..info + }; + assert_eq!(info_roundtrip(Version::Lite05, &info).priority, i8::MAX); assert_eq!(got.max_age, info.max_age); assert_eq!(got.timescale, info.timescale); } diff --git a/rs/moq-net/src/model/bandwidth.rs b/rs/moq-net/src/model/bandwidth.rs index c225bca57c..80ba989a90 100644 --- a/rs/moq-net/src/model/bandwidth.rs +++ b/rs/moq-net/src/model/bandwidth.rs @@ -244,12 +244,13 @@ impl Allocator { /// bit; within a tier the split is max-min fair, so a share asking for less /// than an even cut takes all of it and leaves the difference to the others. /// - /// That is the *publisher's* priority, which is not what orders the local send - /// queue: that ranks by each subscription's own priority, so a subscriber - /// asking for video ahead of audio is served that way whatever this decides. - /// The publisher's is still the right one to divide by, since allocation is a - /// decision about what to *produce*, and there is no single subscriber - /// priority to read when several are watching one track. + /// That is the *publisher's* priority, which the send order only falls back on: + /// it ranks by each subscription's own priority first, so a subscriber asking for + /// video ahead of audio is served that way whatever this decides, and this one + /// breaks the ties (or alone orders a broadcast's tracks on a session scheduled + /// per broadcast). The publisher's is still the right one to divide by, since + /// allocation is a decision about what to *produce*, and there is no single + /// subscriber priority to read when several are watching one track. /// /// That last part carries a publisher that leaves `priority` at its default: /// one tier of audio and video still serves audio's small reservation in full @@ -400,7 +401,7 @@ struct Registry { struct Entry { id: u64, demand: track::Demand, - priority: u8, + priority: i8, max: Rate, } @@ -418,7 +419,7 @@ struct Share { #[derive(Copy, Clone, Debug, Eq, PartialEq)] struct Want { id: u64, - priority: u8, + priority: i8, max: Rate, } @@ -638,15 +639,15 @@ mod tests { use crate::broadcast; /// Priorities matching `hang`'s, which is what the allocator sees in practice. - const AUDIO: u8 = 80; - const VIDEO: u8 = 60; + const AUDIO: i8 = 80; + const VIDEO: i8 = 60; /// Bits per second, so the tables below stay readable. fn bps(bps: u64) -> Rate { Rate::from_bps(bps) } - fn want(id: u64, priority: u8, max: u64) -> Want { + fn want(id: u64, priority: i8, max: u64) -> Want { Want { id, priority, @@ -655,7 +656,7 @@ mod tests { } /// A standalone track at `priority`, plus the broadcast keeping it alive. - fn track(priority: u8) -> (broadcast::Producer, track::Producer) { + fn track(priority: i8) -> (broadcast::Producer, track::Producer) { let broadcast = broadcast::Info::default().produce(); let track = broadcast .create_track("t", track::Info::timed().with_priority(priority)) diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 6498967b55..35e9b32fc6 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -2221,6 +2221,12 @@ impl Consumer { self.track.timescale } + /// The parent track's publisher priority, [`track::Info::priority`]. + #[cfg(feature = "async")] + pub(crate) fn track_priority(&self) -> i8 { + self.track.priority + } + /// The index of the next frame this consumer will return. /// /// Starts at 0, or at the group's first available frame once [`Self::set_frames`] has @@ -2624,8 +2630,8 @@ impl Cursor { #[derive(Clone, Debug, Default)] #[non_exhaustive] pub struct Fetch { - /// Delivery priority for the fetched group's stream. Defaults to 0. - pub priority: u8, + /// Delivery priority for the fetched group's stream, higher first. Defaults to 0. + pub priority: i8, /// Maximum age behind the live edge this caller accepts. /// @@ -2648,7 +2654,7 @@ pub struct Fetch { impl Fetch { /// Set the delivery priority, returning `self` for chaining. - pub fn with_priority(mut self, priority: u8) -> Self { + pub fn with_priority(mut self, priority: i8) -> Self { self.priority = priority; self } @@ -2681,7 +2687,7 @@ pub struct Request { pub(crate) state: kio::Producer, pub(super) fetch: kio::Shared, pub(super) sequence: u64, - pub(super) priority: u8, + pub(super) priority: i8, pub(super) max_delay: Option, pub(super) frame_start: u64, // pending: /quest/m1/public-model/deferred.md diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 81f53a3846..7da302e6be 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -15,8 +15,9 @@ pub(super) use window::{Resolved, Window}; #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] pub struct Subscription { - /// Delivery priority. Higher values preempt lower ones when bandwidth is constrained. - pub priority: u8, + /// Delivery priority, higher first, defaulting to 0. Higher values preempt lower ones + /// when bandwidth is constrained. + pub priority: i8, /// How long a superseded group stays worth delivering to this subscriber. /// /// [`Duration::ZERO`] (the default) skips immediately: group 8 arriving means group 7 @@ -253,7 +254,7 @@ impl Default for Subscription { impl Subscription { /// Set the delivery priority, returning `self` for chaining. - pub fn with_priority(mut self, priority: u8) -> Self { + pub fn with_priority(mut self, priority: i8) -> Self { self.priority = priority; self } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 5a7d2f66c1..76ee2c7f5e 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -40,9 +40,9 @@ use std::{ time::Duration, }; -// The higher-first midpoint. IETF flips priority (lower first), so this goes out as 128, the -// draft's usual publisher priority, while moq-lite carries 127 as written: one urgency on both. -const DEFAULT_PRIORITY: u8 = 127; +// The unset priority, the midpoint of the signed range. It goes out as byte 128 on moq-lite +// and 127 on IETF, which flips priority (lower first): one urgency on both. +const DEFAULT_PRIORITY: i8 = 0; /// Maximum number of datagrams retained in the per-track send buffer. /// @@ -133,10 +133,13 @@ pub struct Info { /// `2^53 - 1` milliseconds to no limit for older JavaScript readers. Sub-millisecond precision is truncated /// (`Duration::as_millis`) at encode time. pub max_age: Option, - /// The publisher's priority for this track, used only to break ties between - /// subscriptions of equal subscriber priority. Reported in TRACK_INFO (Lite05+). - /// Higher is more urgent. Defaults to 127, the midpoint. - pub priority: u8, + /// The publisher's priority for this track, higher first, defaulting to 0. + /// + /// It breaks ties between subscriptions of equal subscriber priority. On a session + /// scheduled per broadcast ([`session::Schedule::Broadcast`](crate::session::Schedule::Broadcast)) + /// it replaces the subscriber's priority, ordering the broadcast's tracks among + /// themselves. Reported in TRACK_INFO (Lite05+). + pub priority: i8, /// The ratio of a group's bytes after its first frame to the first frame past which a /// joining subscriber asks for a new group, or `None` (the default) to never ask. /// @@ -188,8 +191,8 @@ impl Info { self } - /// Set the publisher's tie-break priority, returning `self` for chaining. - pub fn with_priority(mut self, priority: u8) -> Self { + /// Set the publisher's priority, returning `self` for chaining. + pub fn with_priority(mut self, priority: i8) -> Self { self.priority = priority; self } @@ -621,7 +624,7 @@ pub(super) type FetchState = Requests; /// One fetch attempt for a sequence, shared by every [`Fetching`] that joined it. pub(super) struct PendingFetch { // The most demanding delivery priority across the joined fetches. - priority: u8, + priority: i8, // The most tolerant reader budget. None is unbounded and therefore dominates. max_delay: Option, @@ -2300,7 +2303,7 @@ impl Producer { #[cfg(test)] #[cfg(feature = "async")] // private: test - pub(crate) fn publisher_priority(&self) -> u8 { + pub(crate) fn publisher_priority(&self) -> i8 { self.info.priority } @@ -3548,7 +3551,7 @@ impl Demand { } /// The publisher's tie-break priority, as set in [`Info::priority`]. - pub(super) fn priority(&self) -> u8 { + pub(super) fn priority(&self) -> i8 { // Always Some once the track exists; a closed one reads its last value. self.state .read() @@ -4345,7 +4348,7 @@ impl group::Request { } /// The delivery priority the consumer requested for this group. - pub fn priority(&self) -> u8 { + pub fn priority(&self) -> i8 { self.priority } @@ -4497,7 +4500,7 @@ pub struct Fetching { // rather than a short answer. frame_start: u64, // This caller's priority, for a fetch that moves to a front's routes. - priority: u8, + priority: i8, // This caller's own age cap. None means only the publisher's max age. max_delay: Option, // The attempt this fetch joined; `None` on a cache hit, or when no handler diff --git a/rs/moq-net/src/model/track/demand.rs b/rs/moq-net/src/model/track/demand.rs index b415c02694..f96bf56cb1 100644 --- a/rs/moq-net/src/model/track/demand.rs +++ b/rs/moq-net/src/model/track/demand.rs @@ -17,7 +17,7 @@ use ranges::Ranges; #[derive(Default)] pub(super) struct Subscriptions { count: usize, - priority: BTreeMap, + priority: BTreeMap, max_delay: BTreeMap, ranges: Ranges, descending: usize, @@ -32,7 +32,7 @@ pub(super) struct Subscriptions { } /// Everything the aggregate holds besides its ranges. -type Scalars = (u8, Duration, Order); +type Scalars = (i8, Duration, Order); impl Subscriptions { pub(super) fn combined(&self) -> Option { diff --git a/rs/moq-net/src/priority.rs b/rs/moq-net/src/priority.rs index d7340d07fc..98db1b09a8 100644 --- a/rs/moq-net/src/priority.rs +++ b/rs/moq-net/src/priority.rs @@ -17,14 +17,16 @@ use crate::track::Order; // - On remove from Vec: pop highest priority item from overflow to backfill // - On remove from overflow: remove its priority-and-id key in O(log n) // -// Priority ordering: higher track value = higher priority, then subscription, then -// group sequence in that subscription's own direction. +// Priority ordering: higher track value = higher priority, then the publisher's priority, +// then subscription, then group sequence in that subscription's own direction. -/// A group stream's rank: its subscription's track priority, the subscription it -/// belongs to, its group sequence in that subscription's [`Order`], and the first object -/// it carries. +/// A group stream's rank: its subscription's track priority, the publisher's priority for +/// the track, the subscription it belongs to, its group sequence in that subscription's +/// [`Order`], and the first object it carries. /// -/// Higher `track` always wins. `group` breaks ties only *within* one `subscribe`: +/// Higher `track` always wins, and a higher `publisher` breaks a tie between two +/// subscriptions at the same `track`, so hang audio goes ahead of video when a subscriber +/// asks for both at one priority. `group` breaks ties only *within* one `subscribe`: /// newest first by default, so a congested track sheds its backlog rather than its live /// edge, or oldest first for an ascending subscription draining one. /// @@ -36,7 +38,8 @@ use crate::track::Order; /// since the rest of a group is undecodable without its start. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Priority { - track: u8, + track: i8, + publisher: i8, subscribe: u64, order: Order, group: u64, @@ -45,9 +48,10 @@ pub struct Priority { impl Priority { /// Rank a group for transmission: the newest group of a track transmits first. - pub fn new(track: u8, subscribe: u64, group: u64) -> Self { + pub fn new(track: i8, subscribe: u64, group: u64) -> Self { Self { track, + publisher: 0, subscribe, order: Order::Descending, group, @@ -55,6 +59,12 @@ impl Priority { } } + /// Break a tie between subscriptions at the same track priority by the publisher's. + pub fn with_publisher(mut self, publisher: i8) -> Self { + self.publisher = publisher; + self + } + /// Rank groups of the same subscription in `order` instead. pub fn with_order(mut self, order: Order) -> Self { self.order = order; @@ -74,6 +84,7 @@ impl Ord for Priority { other .track .cmp(&self.track) + .then(other.publisher.cmp(&self.publisher)) // Which subscription wins a priority tie is arbitrary, but it has to be // stable and independent of either one's direction. The older (lower id) // subscription goes first. @@ -115,6 +126,13 @@ pub fn in_group(group: u64, start: u64, order: Order) -> i64 { group.saturating_add(i64::from(start == 0)) } +/// A subscription's send group priority: its subscriber priority, then the publisher's. +/// +/// Packed so the subscriber's always dominates, exactly as [`Priority`] ranks them. +pub fn group(subscriber: i8, publisher: i8) -> i64 { + i64::from(subscriber) * 256 + i64::from(publisher) +} + #[derive(Debug, Clone)] struct PriorityItem { id: usize, @@ -388,7 +406,7 @@ impl PriorityState { } /// Change one item's track priority and order, leaving the rest of its rank alone. - fn steer(&mut self, id: usize, track: u8, order: Order) { + fn steer(&mut self, id: usize, track: i8, order: Order) { let mut item = self.extract(id); item.priority.track = track; item.priority.order = order; @@ -419,7 +437,7 @@ pub struct PriorityHandle { id: usize, /// This item's track priority and order, the only parts of its rank a handle owns; /// the rest is fixed when the item is queued. - track: u8, + track: i8, order: Order, /// Last value observed via [`current`](Self::current)/[`next`](Self::next), so /// `next` fires only on a real change. @@ -483,7 +501,7 @@ impl PriorityHandle { /// Change this item's track priority and order and re-sort the queue, returning its /// new rank. No-op if neither changed. - pub fn steer(&mut self, track: u8, order: Order) -> u8 { + pub fn steer(&mut self, track: i8, order: Order) -> u8 { if self.track == track && self.order == order { return self.current(); } @@ -514,7 +532,7 @@ mod tests { /// A live subscription's group rank, on the one subscription these tests share. /// Group order within a subscription is what most of them exercise, so scoping /// them together is what keeps them testing it. - fn live(track: u8, group: u64) -> Priority { + fn live(track: i8, group: u64) -> Priority { Priority::new(track, 0, group) } @@ -573,14 +591,14 @@ mod tests { #[test] fn a_reorder_wakes_only_the_ranks_that_moved() { let queue = PriorityQueue::default(); - let parked = Parked::new((0..64).map(|group| queue.insert(live(100, group))).collect()); + let parked = Parked::new((0..64).map(|group| queue.insert(live(-28, group))).collect()); // Lowest priority yet, so it lands at the tail and shifts nobody. - let tail = queue.insert(live(50, 0)); + let tail = queue.insert(live(-78, 0)); assert_eq!(parked.counts(), vec![0; 64], "a tail insert moved no other rank"); // Highest priority yet, so every existing entry shifts down one. - let front = queue.insert(live(u8::MAX, u64::MAX)); + let front = queue.insert(live(i8::MAX, u64::MAX)); assert_eq!(parked.counts(), vec![1; 64], "a front insert moved every other rank"); drop((tail, front)); @@ -590,8 +608,8 @@ mod tests { #[test] fn an_overflow_removal_wakes_nobody() { let queue = PriorityQueue::default(); - let fillers = (0..MAX_VEC_SIZE as u64).map(|group| queue.insert(live(200, group))); - let overflow = (0..2).map(|group| queue.insert(live(100, group))); + let fillers = (0..MAX_VEC_SIZE as u64).map(|group| queue.insert(live(72, group))); + let overflow = (0..2).map(|group| queue.insert(live(-28, group))); let mut parked = Parked::new(fillers.chain(overflow).collect()); let doomed = parked.handles.pop().expect("overflow handle"); @@ -608,7 +626,7 @@ mod tests { #[test] fn test_single_item() { let queue = PriorityQueue::default(); - let mut handle = queue.insert(live(100, 5)); + let mut handle = queue.insert(live(-28, 5)); assert_eq!(handle.current(), 0); // First item is always index 0 } @@ -618,8 +636,8 @@ mod tests { #[test] fn test_send_order_inverts_rank() { let queue = PriorityQueue::default(); - let mut top = queue.insert(live(200, 0)); - let mut low = queue.insert(live(100, 0)); + let mut top = queue.insert(live(72, 0)); + let mut low = queue.insert(live(-28, 0)); assert_eq!(top.current(), 0); assert_eq!(top.send_order(), 255, "most urgent rank gets the highest send order"); @@ -632,9 +650,9 @@ mod tests { let queue = PriorityQueue::default(); // Insert items with different track priorities - let mut low = queue.insert(live(50, 0)); - let mut high = queue.insert(live(255, 0)); - let mut mid = queue.insert(live(100, 0)); + let mut low = queue.insert(live(-78, 0)); + let mut high = queue.insert(live(127, 0)); + let mut mid = queue.insert(live(-28, 0)); // With sorted vec, indices map exactly to priority order assert_eq!(high.current(), 0); // Highest priority @@ -647,9 +665,9 @@ mod tests { let queue = PriorityQueue::default(); // Same track priority, different groups - let mut group10 = queue.insert(live(100, 10)); - let mut group5 = queue.insert(live(100, 5)); - let mut group1 = queue.insert(live(100, 1)); + let mut group10 = queue.insert(live(-28, 10)); + let mut group5 = queue.insert(live(-28, 5)); + let mut group1 = queue.insert(live(-28, 1)); // Exact index mapping for sorted vec assert_eq!(group10.current(), 0); @@ -662,12 +680,13 @@ mod tests { #[test] fn test_ord_is_total() { let mixed = [ - Priority::new(100, 7, 1), - Priority::new(100, 7, 2), - Priority::new(100, 7, 1).with_order(Order::Ascending), - Priority::new(100, 7, 2).with_order(Order::Ascending), - Priority::new(100, 8, 1), - Priority::new(200, 7, 1), + Priority::new(-28, 7, 1), + Priority::new(-28, 7, 2), + Priority::new(-28, 7, 1).with_order(Order::Ascending), + Priority::new(-28, 7, 2).with_order(Order::Ascending), + Priority::new(-28, 8, 1), + Priority::new(-28, 8, 1).with_publisher(5), + Priority::new(72, 7, 1), ]; for a in mixed { @@ -687,7 +706,7 @@ mod tests { #[test] fn test_ascending_ranks_oldest_first() { let queue = PriorityQueue::default(); - let ascending = |group| Priority::new(100, 0, group).with_order(Order::Ascending); + let ascending = |group| Priority::new(-28, 0, group).with_order(Order::Ascending); let mut group5 = queue.insert(ascending(5)); let mut group1 = queue.insert(ascending(1)); let mut group10 = queue.insert(ascending(10)); @@ -698,7 +717,7 @@ mod tests { // Steering the subscription back to descending flips it once every group moved. for group in [&mut group1, &mut group5, &mut group10] { - group.steer(100, Order::Descending); + group.steer(-28, Order::Descending); } assert_eq!(group10.current(), 0); assert_eq!(group5.current(), 1); @@ -711,7 +730,7 @@ mod tests { fn test_earlier_start_ranks_first_within_a_group() { for order in [Order::Descending, Order::Ascending] { let queue = PriorityQueue::default(); - let stream = |group, start| Priority::new(100, 0, group).with_order(order).with_start(start); + let stream = |group, start| Priority::new(-28, 0, group).with_order(order).with_start(start); // Queued first, so the slab id alone would rank it ahead. let mut rest = queue.insert(stream(5, 3)); let mut head = queue.insert(stream(5, 0)); @@ -729,14 +748,29 @@ mod tests { } } + /// The publisher's priority breaks a tie between subscriptions at one track priority, and + /// only a tie: the subscriber's priority still wins outright. + #[test] + fn test_publisher_priority_breaks_ties() { + let queue = PriorityQueue::default(); + // Older subscriptions win a full tie, so the tiebreak has to beat that. + let mut video = queue.insert(Priority::new(0, 1, 9).with_publisher(20)); + let mut audio = queue.insert(Priority::new(0, 2, 1).with_publisher(40)); + let mut urgent = queue.insert(Priority::new(1, 3, 1).with_publisher(i8::MIN)); + + assert_eq!(urgent.current(), 0, "subscriber priority dominates"); + assert_eq!(audio.current(), 1, "a higher publisher priority wins the tie"); + assert_eq!(video.current(), 2); + } + #[test] fn test_track_priority_overrides_group() { let queue = PriorityQueue::default(); // Lower track priority but higher group - let mut low_track_high_group = queue.insert(live(50, 1000)); + let mut low_track_high_group = queue.insert(live(-78, 1000)); // Higher track priority but lower group - let mut high_track_low_group = queue.insert(live(255, 1)); + let mut high_track_low_group = queue.insert(live(127, 1)); // Track priority should take precedence assert_eq!(high_track_low_group.current(), 0); @@ -747,9 +781,9 @@ mod tests { fn test_removal_on_drop() { let queue = PriorityQueue::default(); - let mut first = queue.insert(live(255, 0)); - let mut second = queue.insert(live(100, 0)); - let mut third = queue.insert(live(50, 0)); + let mut first = queue.insert(live(127, 0)); + let mut second = queue.insert(live(-28, 0)); + let mut third = queue.insert(live(-78, 0)); assert_eq!(first.current(), 0); assert_eq!(second.current(), 1); @@ -767,8 +801,8 @@ mod tests { fn test_removal_of_highest_priority() { let queue = PriorityQueue::default(); - let mut first = queue.insert(live(255, 0)); - let mut second = queue.insert(live(100, 0)); + let mut first = queue.insert(live(127, 0)); + let mut second = queue.insert(live(-28, 0)); assert_eq!(first.current(), 0); assert_eq!(second.current(), 1); @@ -784,8 +818,8 @@ mod tests { fn test_removal_of_lowest_priority() { let queue = PriorityQueue::default(); - let mut first = queue.insert(live(255, 0)); - let mut second = queue.insert(live(100, 0)); + let mut first = queue.insert(live(127, 0)); + let mut second = queue.insert(live(-28, 0)); assert_eq!(first.current(), 0); assert_eq!(second.current(), 1); @@ -802,7 +836,7 @@ mod tests { let queue = PriorityQueue::default(); // Insert items from high to low group to make them ordered in heap - let mut handles: Vec<_> = (0..10).rev().map(|i| queue.insert(live(100, i))).collect(); + let mut handles: Vec<_> = (0..10).rev().map(|i| queue.insert(live(-28, i))).collect(); // Highest group (9, at handles[0]) should be at heap index 0 assert_eq!(handles[0].current(), 0); @@ -818,7 +852,7 @@ mod tests { let queue = PriorityQueue::default(); // Insert more than 255 items (insert high to low so first item is highest priority) - let mut handles: Vec<_> = (0..300).rev().map(|i| queue.insert(live(100, i))).collect(); + let mut handles: Vec<_> = (0..300).rev().map(|i| queue.insert(live(-28, i))).collect(); // Highest priority item (group=299, handles[0]) should be at heap index 0 assert_eq!(handles[0].current(), 0); @@ -839,18 +873,18 @@ mod tests { let queue = PriorityQueue::default(); // Mix of different track priorities and groups - let mut high_track_high_group = queue.insert(live(255, 10)); - let mut high_track_low_group = queue.insert(live(255, 1)); - let mut mid_track_high_group = queue.insert(live(100, 5)); - let mut mid_track_low_group = queue.insert(live(100, 1)); - let mut low_track_high_group = queue.insert(live(50, 100)); + let mut high_track_high_group = queue.insert(live(127, 10)); + let mut high_track_low_group = queue.insert(live(127, 1)); + let mut mid_track_high_group = queue.insert(live(-28, 5)); + let mut mid_track_low_group = queue.insert(live(-28, 1)); + let mut low_track_high_group = queue.insert(live(-78, 100)); // Exact index mapping with sorted vec - assert_eq!(high_track_high_group.current(), 0); // track=255, group=10 - assert_eq!(high_track_low_group.current(), 1); // track=255, group=1 - assert_eq!(mid_track_high_group.current(), 2); // track=100, group=5 - assert_eq!(mid_track_low_group.current(), 3); // track=100, group=1 - assert_eq!(low_track_high_group.current(), 4); // track=50, group=100 + assert_eq!(high_track_high_group.current(), 0); // track=127, group=10 + assert_eq!(high_track_low_group.current(), 1); // track=127, group=1 + assert_eq!(mid_track_high_group.current(), 2); // track=-28, group=5 + assert_eq!(mid_track_low_group.current(), 3); // track=-28, group=1 + assert_eq!(low_track_high_group.current(), 4); // track=-78, group=100 } #[moq_clock::test] @@ -858,10 +892,10 @@ mod tests { let queue = PriorityQueue::default(); // Fill vec to capacity - let mut fillers: Vec<_> = (0..255).rev().map(|i| queue.insert(live(100, i + 100))).collect(); + let mut fillers: Vec<_> = (0..255).rev().map(|i| queue.insert(live(-28, i + 100))).collect(); // This goes to overflow - let mut overflow_item = queue.insert(live(100, 50)); + let mut overflow_item = queue.insert(live(-28, 50)); assert_eq!(overflow_item.current(), u8::MAX); // Spawn task to wait for promotion from overflow @@ -882,9 +916,9 @@ mod tests { fn test_interleaved_insertions_and_removals() { let queue = PriorityQueue::default(); - let mut h1 = queue.insert(live(200, 0)); - let h2 = queue.insert(live(150, 0)); - let mut h3 = queue.insert(live(100, 0)); + let mut h1 = queue.insert(live(72, 0)); + let h2 = queue.insert(live(22, 0)); + let mut h3 = queue.insert(live(-28, 0)); // h1 has highest priority assert_eq!(h1.current(), 0); @@ -896,7 +930,7 @@ mod tests { // h3 should have moved up assert!(h3.current() < 2); - let mut h4 = queue.insert(live(250, 0)); + let mut h4 = queue.insert(live(122, 0)); // h4 has highest priority now assert_eq!(h4.current(), 0); @@ -914,9 +948,9 @@ mod tests { let queue = PriorityQueue::default(); // Items with identical track and group should still be ordered consistently - let mut h1 = queue.insert(live(100, 5)); - let mut h2 = queue.insert(live(100, 5)); - let mut h3 = queue.insert(live(100, 5)); + let mut h1 = queue.insert(live(-28, 5)); + let mut h2 = queue.insert(live(-28, 5)); + let mut h3 = queue.insert(live(-28, 5)); // All three should have valid indices let indices = [h1.current(), h2.current(), h3.current()]; @@ -931,9 +965,9 @@ mod tests { let queue = PriorityQueue::default(); // Create a heap with known structure - let mut root = queue.insert(live(255, 0)); - let left = queue.insert(live(100, 0)); - let mut right = queue.insert(live(100, 0)); + let mut root = queue.insert(live(127, 0)); + let left = queue.insert(live(-28, 0)); + let mut right = queue.insert(live(-28, 0)); assert_eq!(root.current(), 0); @@ -952,21 +986,21 @@ mod tests { // Insert in random order let mut handles = vec![ - queue.insert(live(100, 5)), - queue.insert(live(200, 3)), - queue.insert(live(50, 10)), - queue.insert(live(200, 8)), - queue.insert(live(100, 1)), + queue.insert(live(-28, 5)), + queue.insert(live(72, 3)), + queue.insert(live(-78, 10)), + queue.insert(live(72, 8)), + queue.insert(live(-28, 1)), ]; // Verify highest priority is at index 0 - // track=200, group=8 should be highest + // track=72, group=8 should be highest assert_eq!(handles[3].current(), 0); // Remove highest priority drop(handles.remove(3)); - // Next highest should now be at 0 (track=200, group=3) + // Next highest should now be at 0 (track=72, group=3) assert_eq!(handles[1].current(), 0); } @@ -975,10 +1009,10 @@ mod tests { let queue = PriorityQueue::default(); // Fill vec to capacity - 1 - let _fillers: Vec<_> = (0..254).map(|i| queue.insert(live(100, i + 100))).collect(); + let _fillers: Vec<_> = (0..254).map(|i| queue.insert(live(-28, i + 100))).collect(); // Insert one more that will be at the edge - let mut at_edge = queue.insert(live(100, 50)); + let mut at_edge = queue.insert(live(-28, 50)); assert_eq!(at_edge.current(), 254); // Spawn task to wait for demotion notification @@ -987,7 +1021,7 @@ mod tests { moq_clock::sleep(std::time::Duration::from_millis(10)).await; // Insert very high priority item, kicking at_edge to overflow - let _high = queue.insert(live(255, 1000)); + let _high = queue.insert(live(127, 1000)); let new_priority = task.await.unwrap(); assert_eq!(new_priority, u8::MAX, "Should be demoted to overflow"); @@ -997,16 +1031,16 @@ mod tests { fn test_empty_after_all_removed() { let queue = PriorityQueue::default(); - let h1 = queue.insert(live(100, 0)); - let h2 = queue.insert(live(200, 0)); - let h3 = queue.insert(live(50, 0)); + let h1 = queue.insert(live(-28, 0)); + let h2 = queue.insert(live(72, 0)); + let h3 = queue.insert(live(-78, 0)); drop(h1); drop(h2); drop(h3); // Queue should be empty, next insert should get index 0 - let mut h4 = queue.insert(live(100, 0)); + let mut h4 = queue.insert(live(-28, 0)); assert_eq!(h4.current(), 0); } @@ -1014,11 +1048,11 @@ mod tests { fn test_steer_track_reorders() { let queue = PriorityQueue::default(); - // Subscription 1 (track=255), Subscription 2 (track=55) - let mut s1_g1 = queue.insert(live(255, 1)); - let mut s1_g2 = queue.insert(live(255, 2)); - let mut s2_g1 = queue.insert(live(55, 1)); - let mut s2_g2 = queue.insert(live(55, 2)); + // Subscription 1 (track=127), Subscription 2 (track=-73) + let mut s1_g1 = queue.insert(live(127, 1)); + let mut s1_g2 = queue.insert(live(127, 2)); + let mut s2_g1 = queue.insert(live(-73, 1)); + let mut s2_g2 = queue.insert(live(-73, 2)); assert_eq!(s1_g2.current(), 0); // s1 highest assert_eq!(s1_g1.current(), 1); @@ -1026,10 +1060,10 @@ mod tests { assert_eq!(s2_g1.current(), 3); // Swap track priorities for each handle individually. - s1_g1.steer(55, Order::Descending); - s1_g2.steer(55, Order::Descending); - s2_g1.steer(255, Order::Descending); - s2_g2.steer(255, Order::Descending); + s1_g1.steer(-73, Order::Descending); + s1_g2.steer(-73, Order::Descending); + s2_g1.steer(127, Order::Descending); + s2_g2.steer(127, Order::Descending); assert_eq!(s2_g2.current(), 0); // s2 now highest assert_eq!(s2_g1.current(), 1); @@ -1042,8 +1076,8 @@ mod tests { let queue = PriorityQueue::default(); // h_low at index 1, will be promoted to 0 when h_high is demoted. - let mut h_high = queue.insert(live(255, 1)); - let mut h_low = queue.insert(live(50, 1)); + let mut h_high = queue.insert(live(127, 1)); + let mut h_low = queue.insert(live(-78, 1)); assert_eq!(h_low.current(), 1); @@ -1052,7 +1086,7 @@ mod tests { moq_clock::sleep(std::time::Duration::from_millis(10)).await; // Demote h_high below h_low. - h_high.steer(10, Order::Descending); + h_high.steer(-118, Order::Descending); let new_priority = task.await.unwrap(); assert_eq!(new_priority, 0, "h_low should be promoted to the top"); @@ -1062,16 +1096,16 @@ mod tests { fn test_steer_track_self() { let queue = PriorityQueue::default(); - let mut h_high = queue.insert(live(255, 1)); - let mut h_mid = queue.insert(live(100, 1)); - let mut h_low = queue.insert(live(50, 1)); + let mut h_high = queue.insert(live(127, 1)); + let mut h_mid = queue.insert(live(-28, 1)); + let mut h_low = queue.insert(live(-78, 1)); assert_eq!(h_high.current(), 0); assert_eq!(h_mid.current(), 1); assert_eq!(h_low.current(), 2); // Demote h_high below the others. - h_high.steer(10, Order::Descending); + h_high.steer(-118, Order::Descending); assert_eq!(h_mid.current(), 0); assert_eq!(h_low.current(), 1); @@ -1082,19 +1116,19 @@ mod tests { fn test_steer_track_swaps_demoted_vec_item_with_overflow() { let queue = PriorityQueue::default(); - // Fill vec with 255 items at track=100, groups 1..=255. + // Fill vec with 255 items at track=-28, groups 1..=255. // f1 (group=1) is the vec tail (lowest priority of the fillers). - let mut fillers: Vec<_> = (1..=255u64).map(|g| queue.insert(live(100, g))).collect(); + let mut fillers: Vec<_> = (1..=255u64).map(|g| queue.insert(live(-28, g))).collect(); // Insert a higher-track item; this kicks f1 out of vec into overflow. - let mut top = queue.insert(live(200, 0)); + let mut top = queue.insert(live(72, 0)); assert_eq!(top.current(), 0); assert_eq!(fillers[0].current(), u8::MAX, "f1 was kicked into overflow"); // Lower top's track below every filler. Without the swap, top would land // in vec at the tail while f1 stays in overflow despite having higher // priority, breaking the "every overflow item < every vec item" invariant. - top.steer(0, Order::Descending); + top.steer(-128, Order::Descending); assert!(fillers[0].current() < u8::MAX, "f1 should be promoted back into vec"); assert_eq!(top.current(), u8::MAX, "demoted top should land in overflow"); @@ -1105,15 +1139,15 @@ mod tests { let queue = PriorityQueue::default(); // Three items, all in vec; no overflow involvement. - let mut a = queue.insert(live(200, 0)); - let mut b = queue.insert(live(100, 0)); - let mut c = queue.insert(live(50, 0)); + let mut a = queue.insert(live(72, 0)); + let mut b = queue.insert(live(-28, 0)); + let mut c = queue.insert(live(-78, 0)); assert_eq!(a.current(), 0); assert_eq!(b.current(), 1); assert_eq!(c.current(), 2); // Lowering A's priority below B but above C should leave A at index 1. - a.steer(75, Order::Descending); + a.steer(-53, Order::Descending); assert_eq!(b.current(), 0); assert_eq!(a.current(), 1); assert_eq!(c.current(), 2); @@ -1123,13 +1157,13 @@ mod tests { fn test_remove_promotes_highest_priority_overflow_item() { let queue = PriorityQueue::default(); - // Fill vec to capacity with track=200. - let fillers: Vec<_> = (100..355u64).map(|g| queue.insert(live(200, g))).collect(); + // Fill vec to capacity with track=72. + let fillers: Vec<_> = (100..355u64).map(|g| queue.insert(live(72, g))).collect(); // Three overflow items with distinct priorities (same track, different groups). - let mut low = queue.insert(live(100, 1)); - let mut mid = queue.insert(live(100, 2)); - let mut high = queue.insert(live(100, 3)); + let mut low = queue.insert(live(-28, 1)); + let mut mid = queue.insert(live(-28, 2)); + let mut high = queue.insert(live(-28, 3)); assert_eq!(low.current(), u8::MAX); assert_eq!(mid.current(), u8::MAX); assert_eq!(high.current(), u8::MAX); @@ -1152,8 +1186,8 @@ mod tests { let queue = PriorityQueue::default(); // Fill vec, then insert top, kicking f1 (filler at group=1) into overflow. - let mut fillers: Vec<_> = (1..=255u64).map(|g| queue.insert(live(100, g))).collect(); - let mut top = queue.insert(live(200, 0)); + let mut fillers: Vec<_> = (1..=255u64).map(|g| queue.insert(live(-28, g))).collect(); + let mut top = queue.insert(live(72, 0)); assert_eq!(top.current(), 0); // Take ownership of f1 so we can await its promotion notification. @@ -1164,7 +1198,7 @@ mod tests { moq_clock::sim::yield_now().await; // Demoting top below every filler swaps it with f1 in overflow. - top.steer(0, Order::Descending); + top.steer(-128, Order::Descending); let promoted = task.await.unwrap(); assert!(promoted < u8::MAX, "f1 should be notified of promotion"); diff --git a/rs/moq-net/src/scheduler.rs b/rs/moq-net/src/scheduler.rs new file mode 100644 index 0000000000..44f4ad9886 --- /dev/null +++ b/rs/moq-net/src/scheduler.rs @@ -0,0 +1,135 @@ +//! Where a session's publisher places the streams it serves, following its [`Schedule`]. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex, Weak}, +}; + +use crate::{Path, session::Schedule}; + +type Registry = Mutex>>>; + +/// Ranks what a session's publisher serves: one strict order, or a send domain per broadcast. +pub(crate) struct Scheduler { + schedule: Schedule, + /// Each served broadcast's send domain, while anything of it is in flight. + domains: Arc>, +} + +impl Clone for Scheduler { + fn clone(&self) -> Self { + Self { + schedule: self.schedule, + domains: self.domains.clone(), + } + } +} + +/// A broadcast's send domain, released once the last handle is. +pub(crate) type Domain = Arc>; + +pub(crate) struct Held { + domain: D, + broadcast: String, + domains: Weak>, +} + +impl Held { + /// The transport's domain. + pub fn get(&self) -> &D { + &self.domain + } +} + +impl Drop for Held { + fn drop(&mut self) { + let Some(domains) = self.domains.upgrade() else { + return; + }; + let mut domains = domains.lock().unwrap(); + // A later stream of the broadcast may have opened a fresh domain since this one's last + // handle dropped, and that entry stays. + if domains + .get(&self.broadcast) + .is_some_and(|entry| entry.strong_count() == 0) + { + domains.remove(&self.broadcast); + } + } +} + +impl Scheduler { + pub fn new(schedule: Schedule) -> Self { + Self { + schedule, + domains: Default::default(), + } + } + + /// The subscriber priority a peer's request is served at: as asked on a strict session, + /// the unset default on one scheduled per broadcast, so it neither ranks the request's + /// streams nor reaches the publisher upstream. + pub fn subscriber(&self, requested: i8) -> i8 { + match self.schedule { + Schedule::Strict => requested, + Schedule::Broadcast => 0, + } + } + + /// The send domain for streams of `broadcast`: `None`, the session's default, on a strict + /// session, or the broadcast's own, opened with `open` unless one is already live. + /// + /// Hold the returned handle for as long as any stream of the broadcast is in flight. + pub fn domain(&self, broadcast: &Path, open: impl FnOnce() -> Option) -> Option> { + if self.schedule == Schedule::Strict { + return None; + } + + let mut domains = self.domains.lock().unwrap(); + if let Some(held) = domains.get(broadcast.as_str()).and_then(Weak::upgrade) { + return Some(held); + } + // A session scheduled per broadcast refuses to start on a transport without domains. + let held = Arc::new(Held { + domain: open()?, + broadcast: broadcast.as_str().to_owned(), + domains: Arc::downgrade(&self.domains), + }); + domains.insert(held.broadcast.clone(), Arc::downgrade(&held)); + Some(held) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strict_uses_the_default_domain() { + let scheduler = Scheduler::::new(Schedule::Strict); + assert!(scheduler.domain(&Path::new("a"), || Some(1)).is_none()); + assert_eq!(scheduler.subscriber(9), 9); + } + + /// Streams of one broadcast share a domain while any is in flight, and the next one after + /// the last left opens a fresh domain. + #[test] + fn one_domain_per_live_broadcast() { + let scheduler = Scheduler::new(Schedule::Broadcast); + let mut opened = 0; + let mut open = || { + opened += 1; + Some(opened) + }; + let a = scheduler.domain(&Path::new("a"), &mut open).unwrap(); + let again = scheduler.domain(&Path::new("a"), &mut open).unwrap(); + let b = scheduler.domain(&Path::new("b"), &mut open).unwrap(); + assert_eq!((*a.get(), *again.get(), *b.get()), (1, 1, 2)); + assert_eq!(scheduler.subscriber(9), 0, "the peer's priority is ignored"); + + drop((a, again)); + assert_eq!(scheduler.domains.lock().unwrap().len(), 1, "a's entry left with it"); + let a = scheduler.domain(&Path::new("a"), &mut open).unwrap(); + assert_eq!(*a.get(), 3); + } +} diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 27f5c9fc70..b73bf04fa1 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -69,6 +69,7 @@ pub struct Server { stats: stats::Session, versions: Versions, limits: crate::session::Limits, + schedule: crate::session::Schedule, extensions: setup::Extensions, } @@ -114,6 +115,13 @@ impl Server { self } + /// Share bandwidth between the broadcasts each session serves as `schedule` says. + /// Defaults to [`Schedule::Strict`](crate::session::Schedule::Strict). + pub fn with_schedule(mut self, schedule: crate::session::Schedule) -> Self { + self.schedule = schedule; + self + } + /// Restrict which protocol versions to accept, in preference order. /// Defaults to every version this crate supports. pub fn with_versions(mut self, versions: Versions) -> Self { @@ -178,6 +186,7 @@ impl Server { runtime: runtime.clone(), client: false, limits: self.limits, + schedule: self.schedule, session: session.clone(), setup_stream: None, publish, @@ -739,6 +748,7 @@ where runtime: runtime.clone(), client: false, limits: server.limits, + schedule: server.schedule, session: session.clone(), setup_stream: Some(stream), publish, @@ -903,6 +913,14 @@ where self } + /// Set how the session shares bandwidth between the broadcasts it serves, after + /// authorizing the peer and before answering SETUP. Overrides any value from the + /// [`Server`] builder. + pub fn with_schedule(mut self, schedule: crate::session::Schedule) -> Self { + self.inner_mut().server.schedule = schedule; + self + } + fn inner_mut(&mut self) -> &mut RequestInner { self.inner.as_mut().expect("request already responded") } @@ -1082,7 +1100,15 @@ mod tests { ) -> std::task::Poll> { std::task::Poll::Pending } - fn send_group(&mut self, _: i64) -> Option { + fn send_domain(&mut self) -> Option { + None + } + + fn send_group( + &mut self, + _: Option<&crate::transport::poll::Ungrouped>, + _: i64, + ) -> Option { None } diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 09a0d80bac..61aa536a5b 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -750,6 +750,30 @@ impl Limits { } } +/// How a session shares its bandwidth between the broadcasts it serves. +/// +/// Priority orders streams only within one scheduling domain. Where one party owns both +/// ends of a session, like a publisher and its ingest relay or an edge relay and a viewer, +/// that domain is the whole session. A relay-to-relay session carries every tenant's +/// broadcasts, so there each broadcast is its own domain. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum Schedule { + /// One strict order across the session: each subscription's priority, the publisher's + /// [`track::Info::priority`](crate::track::Info::priority) breaking ties. + #[default] + Strict, + /// Each broadcast gets an equal share of bytes, however many subscriptions or fetches it + /// carries, and the publisher's [`track::Info::priority`](crate::track::Info::priority) + /// orders the streams inside it. The peer's subscriber priority is ignored, neither + /// ranking its streams nor forwarded upstream. + /// + /// Only moq-lite sessions on a transport with send domains (QUIC and WebTransport over + /// moq-quic) can share by broadcast. Any other session, over WebSocket, iroh, or + /// moq-transport, serves [`Self::Strict`]. + Broadcast, +} + /// A count of live entries shared across a session, refused past its cap. #[derive(Clone)] pub(crate) struct Slots { diff --git a/rs/moq-net/src/transport.rs b/rs/moq-net/src/transport.rs index ddde6f5065..1318fab09c 100644 --- a/rs/moq-net/src/transport.rs +++ b/rs/moq-net/src/transport.rs @@ -220,16 +220,23 @@ pub mod poll { options: SendOptions<'_, ::Group>, ) -> Poll, Self::Error>>; - /// Open a send group at `priority`, or `None` when the transport orders streams by - /// their own priority alone. + /// Open a send domain, or `None` when the transport has none. + /// + /// Domains share the bytes left after the ungrouped streams equally, however many + /// groups each holds, and the session's default domain is one of them. The domain is + /// released once its last handle is dropped and its groups are. + fn send_domain(&mut self) -> Option>; + + /// Open a send group at `priority` in `domain`, or the session's default domain, or + /// `None` when the transport orders streams by their own priority alone. /// /// Streams that join a group, through [`SendOptions`] or [`SendStream::set_group`], and - /// datagrams sent in it share bytes with the session's - /// other groups: a higher priority group preempts every lower one, and groups of equal - /// priority get an equal share of bytes however many streams each has queued. Streams - /// outside every group go before all of them. The group is released once its last - /// handle is dropped and its streams finish. - fn send_group(&mut self, priority: i64) -> Option<::Group>; + /// datagrams sent in it share their domain's bytes with its other groups: a higher + /// priority group preempts every lower one, and groups of equal priority get an equal + /// share of bytes however many streams each has queued. Streams outside every group go + /// before all of them. The group is released once its last handle is dropped and its + /// streams finish. + fn send_group(&mut self, domain: Option<&DomainOf>, priority: i64) -> Option>; /// Send a datagram scheduled by `options`, best-effort. /// @@ -398,7 +405,7 @@ pub mod poll { /// requires until it too becomes named machines. Implemented automatically. pub trait Boxable: Session< - SendStream: MaybeSend + SendStream, + SendStream: MaybeSend + SendStream>, RecvStream: MaybeSend, Error: MaybeSend, > + MaybeSend @@ -408,7 +415,7 @@ pub mod poll { impl Boxable for S where S: Session< - SendStream: MaybeSend + SendStream, + SendStream: MaybeSend + SendStream>, RecvStream: MaybeSend, Error: MaybeSend, > + MaybeSend @@ -417,8 +424,10 @@ pub mod poll { } /// The send group of a session's streams. - #[cfg(feature = "async")] - pub(crate) type GroupOf = <::SendStream as SendStream>::Group; + pub type GroupOf = <::SendStream as SendStream>::Group; + + /// The send domain of a session's groups. + pub type DomainOf = as SendGroup>::Domain; /// How an outgoing stream or datagram is scheduled, like W3C WebTransport's send options. /// @@ -453,7 +462,10 @@ pub mod poll { /// /// Clones share the group. pub trait SendGroup: Clone + 'static { - /// Change the group's priority: higher preempts lower. + /// The transport's send domain, from [`Session::send_domain`]. Clones share the domain. + type Domain: Clone + 'static; + + /// Change the group's priority: higher preempts lower within its domain. fn set_priority(&mut self, priority: i64); } @@ -462,6 +474,8 @@ pub mod poll { pub enum Ungrouped {} impl SendGroup for Ungrouped { + type Domain = Ungrouped; + fn set_priority(&mut self, _: i64) { match *self {} } diff --git a/rs/moq-net/src/transport/memory.rs b/rs/moq-net/src/transport/memory.rs index db944fd01f..68fb43f7ef 100644 --- a/rs/moq-net/src/transport/memory.rs +++ b/rs/moq-net/src/transport/memory.rs @@ -302,7 +302,15 @@ impl poll::Session for Session { Poll::Ready(opened.map(|()| (send, recv))) } - fn send_group(&mut self, _: i64) -> Option { + fn send_domain(&mut self) -> Option { + None + } + + fn send_group( + &mut self, + _: Option<&crate::transport::poll::Ungrouped>, + _: i64, + ) -> Option { None } diff --git a/rs/moq-net/tests/integration/subscribe_priority.rs b/rs/moq-net/tests/integration/subscribe_priority.rs index 2af297a313..1ec161c36b 100644 --- a/rs/moq-net/tests/integration/subscribe_priority.rs +++ b/rs/moq-net/tests/integration/subscribe_priority.rs @@ -27,8 +27,8 @@ const VERSIONS: &[&str] = &[ "moq-transport-22", ]; -const LOW: u8 = 1; -const HIGH: u8 = 200; +const LOW: i8 = -100; +const HIGH: i8 = 100; fn produce_origin(hop: u64) -> origin::Producer { let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); @@ -37,7 +37,7 @@ fn produce_origin(hop: u64) -> origin::Producer { } /// Wait until the subscription `track` serves asks for `want`. -async fn priority(track: &moq_net::track::Producer, want: u8) -> Result<(), String> { +async fn priority(track: &moq_net::track::Producer, want: i8) -> Result<(), String> { let mut track = track.clone(); let reached = moq_clock::timeout(TIMEOUT, async { while track @@ -85,7 +85,7 @@ async fn follows(version: Version, relay: bool) -> Result<(), String> { .await .expect("resolve timeout") .expect("broadcast resolves"); - let subscribe = |priority: u8| { + let subscribe = |priority: i8| { let track = remote.track("video").unwrap(); async move { moq_clock::timeout( diff --git a/rs/moq-net/tests/integration/support/mock.rs b/rs/moq-net/tests/integration/support/mock.rs index 3ee81a8a73..42cf675cd8 100644 --- a/rs/moq-net/tests/integration/support/mock.rs +++ b/rs/moq-net/tests/integration/support/mock.rs @@ -605,7 +605,11 @@ impl poll::Session for MockSession { Poll::Ready(Ok(send)) } - fn send_group(&mut self, _: i64) -> Option { + fn send_domain(&mut self) -> Option { + None + } + + fn send_group(&mut self, _: Option<&poll::Ungrouped>, _: i64) -> Option { None } diff --git a/rs/moq-quic/src/connection/mod.rs b/rs/moq-quic/src/connection/mod.rs index 8ce51c2562..72ed0480d9 100644 --- a/rs/moq-quic/src/connection/mod.rs +++ b/rs/moq-quic/src/connection/mod.rs @@ -93,7 +93,7 @@ pub use stats::{ConnectionStats, CreditStats, FrameStats, PathStats, UdpStats}; mod streams; pub(crate) use streams::StreamsState; pub use streams::{ - Chunks, ClosedStream, Credit, FinishError, ReadError, ReadableError, RecvStream, SendGroup, + Chunks, ClosedStream, Credit, FinishError, ReadError, ReadableError, RecvStream, SendDomain, SendGroup, SendGroups, SendOptions, SendStream, ShouldTransmit, StreamEvent, Streams, WriteError, Written, }; diff --git a/rs/moq-quic/src/connection/streams/mod.rs b/rs/moq-quic/src/connection/streams/mod.rs index c3f160c362..5411b42b33 100644 --- a/rs/moq-quic/src/connection/streams/mod.rs +++ b/rs/moq-quic/src/connection/streams/mod.rs @@ -546,26 +546,15 @@ pub struct SendGroups<'a> { } impl SendGroups<'_> { - /// Open a send group at `priority` - pub fn open(&mut self, priority: i64) -> SendGroup { - SendGroup { - index: self.state.pending.open_group(priority, None), - tag: self.state.tag, - } - } - - /// Open a send group at `priority` in `domain` + /// Open a send group at `priority` in `domain`, or the connection's default domain /// /// # Panics /// - when `domain` was opened on another connection - #[cfg(test)] - pub(crate) fn open_in(&mut self, priority: i64, domain: &SendDomain) -> SendGroup { + pub fn open(&mut self, domain: Option<&SendDomain>, priority: i64) -> SendGroup { let tag = self.state.tag; + let domain = domain.map(|domain| domain.index(tag)); SendGroup { - index: self - .state - .pending - .open_group(priority, Some(domain.index(tag))), + index: self.state.pending.open_group(priority, domain), tag, } } @@ -599,8 +588,9 @@ impl SendGroups<'_> { } /// Open a domain, which gets an equal share of bytes with every other domain - #[cfg(test)] - pub(crate) fn open_domain(&mut self) -> SendDomain { + /// + /// The default domain, holding every group opened without one, is one of them. + pub fn open_domain(&mut self) -> SendDomain { SendDomain { index: self.state.pending.open_domain(), tag: self.state.tag, @@ -611,8 +601,7 @@ impl SendGroups<'_> { /// /// # Panics /// - when `domain` was opened on another connection - #[cfg(test)] - pub(crate) fn close_domain(&mut self, domain: SendDomain) { + pub fn close_domain(&mut self, domain: SendDomain) { let index = domain.index(self.state.tag); self.state.pending.close_domain(index); } @@ -708,15 +697,13 @@ impl SendGroup { /// A domain of send groups, from [`SendGroups::open_domain`] /// /// Release it with [`SendGroups::close_domain`] on the connection that opened it. -#[cfg(test)] #[derive(Debug, PartialEq, Eq)] #[must_use = "a domain lives until it is closed"] -pub(crate) struct SendDomain { +pub struct SendDomain { index: usize, tag: ConnectionTag, } -#[cfg(test)] impl SendDomain { fn index(&self, tag: ConnectionTag) -> usize { assert_eq!(self.tag, tag, "send domain used on another connection"); diff --git a/rs/moq-quic/src/connection/streams/saturated.rs b/rs/moq-quic/src/connection/streams/saturated.rs index 6e1bbb30df..1000857d00 100644 --- a/rs/moq-quic/src/connection/streams/saturated.rs +++ b/rs/moq-quic/src/connection/streams/saturated.rs @@ -90,7 +90,7 @@ impl Sender { } fn group(&mut self, priority: i64) -> SendGroup { - self.groups().open(priority) + self.groups().open(None, priority) } fn send(&mut self, id: StreamId) -> SendStream<'_> { @@ -448,13 +448,13 @@ fn domains_share_bytes_and_confine_priority() { let quiet = sender.groups().open_domain(); let crowded_groups: Vec<_> = (0..10) - .map(|priority| sender.groups().open_in(priority, &crowded)) + .map(|priority| sender.groups().open(Some(&crowded), priority)) .collect(); let crowded_streams: Vec<_> = crowded_groups .iter() .map(|group| sender.open(Some(group), 0, 1_000_000)) .collect(); - let quiet_group = sender.groups().open_in(-100, &quiet); + let quiet_group = sender.groups().open(Some(&quiet), -100); let quiet_stream = sender.open(Some(&quiet_group), 0, 1_000_000); sender.drain(200); @@ -472,7 +472,7 @@ fn domains_share_bytes_and_confine_priority() { fn the_default_domain_is_one_share() { let mut sender = Sender::new(); let domain = sender.groups().open_domain(); - let inside = sender.groups().open_in(0, &domain); + let inside = sender.groups().open(Some(&domain), 0); let outside: Vec = (0..3).map(|_| sender.group(0)).collect(); let inside = sender.open(Some(&inside), 0, 1_000_000); let outside: Vec<_> = outside @@ -490,7 +490,7 @@ fn the_default_domain_is_one_share() { fn reset_frees_a_released_group() { let mut sender = Sender::new(); let domain: SendDomain = sender.groups().open_domain(); - let group = sender.groups().open_in(0, &domain); + let group = sender.groups().open(Some(&domain), 0); let reset = sender.open(Some(&group), 0, 10_000); let finished = sender.open(Some(&group), 1, 100); sender.send(finished).finish().unwrap(); @@ -673,8 +673,8 @@ fn saturated_datagram_domains_share_bytes() { let mut sender = Sender::new(); let small = sender.groups().open_domain(); let large = sender.groups().open_domain(); - let small = sender.groups().open_in(0, &small); - let large = sender.groups().open_in(9, &large); + let small = sender.groups().open(Some(&small), 0); + let large = sender.groups().open(Some(&large), 9); let small: Vec<_> = (0..2_000) .map(|seq| sender.datagram(Some(&small), seq, 50)) .collect(); diff --git a/rs/moq-quic/src/connection/streams/schedule.rs b/rs/moq-quic/src/connection/streams/schedule.rs index 1b73fdd08a..a4c2499e67 100644 --- a/rs/moq-quic/src/connection/streams/schedule.rs +++ b/rs/moq-quic/src/connection/streams/schedule.rs @@ -291,13 +291,11 @@ impl Schedule { } /// Create a domain, sharing bytes equally with every other domain. - #[cfg(test)] pub(super) fn open_domain(&mut self) -> usize { self.domains.insert(Domain::new()) } /// Release a domain; it is freed once its last group is. - #[cfg(test)] pub(super) fn close_domain(&mut self, domain: usize) { assert_ne!(domain, DEFAULT_DOMAIN, "the default domain is never closed"); self.domains[domain].closed = true; diff --git a/rs/moq-quic/src/connection/streams/state.rs b/rs/moq-quic/src/connection/streams/state.rs index afdc4185bd..8682edda7c 100644 --- a/rs/moq-quic/src/connection/streams/state.rs +++ b/rs/moq-quic/src/connection/streams/state.rs @@ -3359,8 +3359,8 @@ mod tests { fn send_group_from_another_connection() { let mut a = make(Side::Client); let mut b = make(Side::Client); - let group = crate::SendGroups { state: &mut a }.open(0); - let _other = crate::SendGroups { state: &mut b }.open(0); + let group = crate::SendGroups { state: &mut a }.open(None, 0); + let _other = crate::SendGroups { state: &mut b }.open(None, 0); crate::SendGroups { state: &mut b }.set_priority(&group, 1); } @@ -3371,7 +3371,7 @@ mod tests { let mut b = make(Side::Client); let domain = crate::SendGroups { state: &mut a }.open_domain(); let _other = crate::SendGroups { state: &mut b }.open_domain(); - let _ = crate::SendGroups { state: &mut b }.open_in(0, &domain); + let _ = crate::SendGroups { state: &mut b }.open(Some(&domain), 0); } /// Another connection holding at least as much credit still refuses it. diff --git a/rs/moq-quic/src/lib.rs b/rs/moq-quic/src/lib.rs index a52b3f2dd3..4fc4aff619 100644 --- a/rs/moq-quic/src/lib.rs +++ b/rs/moq-quic/src/lib.rs @@ -55,7 +55,7 @@ mod connection; pub use crate::connection::{ Chunk, Chunks, ClosedStream, Connection, ConnectionError, ConnectionStats, Credit, CreditStats, Datagrams, Event, FinishError, FrameStats, PathStats, ReadError, ReadableError, RecvStream, - RttEstimator, SendDatagramError, SendGroup, SendGroups, SendOptions, SendStream, + RttEstimator, SendDatagramError, SendDomain, SendGroup, SendGroups, SendOptions, SendStream, ShouldTransmit, StreamEvent, Streams, UdpStats, WriteError, Written, }; #[cfg(feature = "qlog")] diff --git a/rs/moq-quic/src/tests/mod.rs b/rs/moq-quic/src/tests/mod.rs index 74e8509e48..dd11c6ed99 100644 --- a/rs/moq-quic/src/tests/mod.rs +++ b/rs/moq-quic/src/tests/mod.rs @@ -3339,8 +3339,8 @@ fn first_flight_datagrams(stream: i64, datagram: i64) -> usize { let mut pair = Pair::default(); let (client_ch, server_ch) = pair.connect(); let conn = pair.server_conn_mut(server_ch); - let streams = conn.send_groups().open(stream); - let datagrams = conn.send_groups().open(datagram); + let streams = conn.send_groups().open(None, stream); + let datagrams = conn.send_groups().open(None, datagram); let s = conn .streams() .open( diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index c28c8304f5..0329dfdd8e 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -832,6 +832,25 @@ pub struct Config { setting = "cluster.tier" )] pub tier: Option, + + /// Share each relay-to-relay session's bandwidth equally between its broadcasts. + /// Boolean flag: pass `--cluster-fair` (or `=true` / `=false`). Off by default. + /// + /// Off, a peer session orders every stream by the downstream subscriber's priority, + /// so one tenant's broadcast can starve another's on a congested link. On, each + /// broadcast gets an equal share however many tracks or subscribers it has, the + /// publisher's track priority alone orders the streams inside it, and the subscriber + /// priorities from downstream are not forwarded. Sessions with clients are unchanged, + /// as are peer sessions over a transport without send domains (WebSocket, iroh) or + /// over moq-transport, which stay strict. + #[usage( + name = "cluster-fair", + long = "cluster-fair", + env = "MOQ_CLUSTER_FAIR", + setting = "cluster.fair", + bool_value + )] + pub fair: bool, } impl Config { @@ -1170,6 +1189,23 @@ impl Cluster { } } + /// How an admitted session shares bandwidth: per broadcast for a `peer` grant when + /// [`Config::fair`] is on, strictly otherwise. + pub(crate) fn schedule(&self, lease: &auth::Lease) -> moq_net::session::Schedule { + match lease.token().peer { + true => self.peer_schedule(), + false => moq_net::session::Schedule::Strict, + } + } + + /// How a relay-to-relay session shares bandwidth. See [`Config::fair`]. + fn peer_schedule(&self) -> moq_net::session::Schedule { + match self.config.fair { + true => moq_net::session::Schedule::Broadcast, + false => moq_net::session::Schedule::Strict, + } + } + /// The QUIC limits an admitted session widens to, if any: only a `peer` grant does. #[cfg(any(feature = "quic", all(target_os = "linux", feature = "_uring")))] pub(crate) fn quic_limits(&self, lease: &auth::Lease) -> Option { @@ -2029,7 +2065,8 @@ impl Cluster { .with_publisher(origin.consume().with_hidden(true)) .with_subscriber(origin) .with_stats(stats.clone()) - .with_limits(self.peer_limits); + .with_limits(self.peer_limits) + .with_schedule(self.peer_schedule()); if let Some(cost) = target.cost { client = client.with_cost(cost); } @@ -2340,6 +2377,28 @@ mod tests { } } + /// `cluster.fair` schedules only relay-to-relay sessions per broadcast. + #[tokio::test] + async fn fair_scheduling_follows_the_peer_grant() { + use moq_net::session::Schedule; + for (fair, peer, expected) in [ + (false, false, Schedule::Strict), + (false, true, Schedule::Strict), + (true, false, Schedule::Strict), + (true, true, Schedule::Broadcast), + ] { + let mut options = Options::default(); + options.config.fair = fair; + let cluster = Cluster::new(options).unwrap(); + let mut grant = moq_auth::Grant::new(moq_auth::Patterns::new(), moq_auth::Patterns::new()); + grant.peer = peer; + let (_producer, consumer) = moq_auth::lease::Producer::new(grant); + let lease = auth::Lease::new("/.cluster/peer", consumer); + assert_eq!(cluster.schedule(&lease), expected, "fair {fair}, peer {peer}"); + assert_eq!(cluster.peer_schedule(), if fair { Schedule::Broadcast } else { Schedule::Strict }); + } + } + /// A millisecond track: the frames written here carry timestamps. fn timed() -> moq_net::track::Info { moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI) diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index e262492596..ddd6154afe 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -123,7 +123,8 @@ impl Connection { // publish-only or subscribe-only session. let mut request = request .with_stats(stats.clone()) - .with_limits(lifecycle.cluster.limits(&lease)); + .with_limits(lifecycle.cluster.limits(&lease)) + .with_schedule(lifecycle.cluster.schedule(&lease)); if let Some(subscriber) = subscriber { request = request.with_publisher(subscriber); } diff --git a/rs/moq-relay/src/incoming.rs b/rs/moq-relay/src/incoming.rs index e00f7cbff6..ab3e5e3e3d 100644 --- a/rs/moq-relay/src/incoming.rs +++ b/rs/moq-relay/src/incoming.rs @@ -60,6 +60,7 @@ pub trait Sealed: Sized { fn role(&self) -> Option; fn with_stats(self, stats: moq_net::stats::Session) -> Self; fn with_limits(self, limits: moq_net::session::Limits) -> Self; + fn with_schedule(self, schedule: moq_net::session::Schedule) -> Self; fn with_publisher(self, publish: moq_net::origin::Consumer) -> Self; fn with_subscriber(self, subscribe: moq_net::origin::Producer) -> Self; @@ -117,6 +118,10 @@ impl Sealed for moq_tokio::server::Request { moq_tokio::server::Request::with_limits(self, limits) } + fn with_schedule(self, schedule: moq_net::session::Schedule) -> Self { + moq_tokio::server::Request::with_schedule(self, schedule) + } + fn with_publisher(self, publish: moq_net::origin::Consumer) -> Self { moq_tokio::server::Request::with_publisher(self, publish) } @@ -179,6 +184,11 @@ impl Sealed for Accepted { self } + fn with_schedule(mut self, schedule: moq_net::session::Schedule) -> Self { + self.handshake = self.handshake.with_schedule(schedule); + self + } + fn with_publisher(mut self, publish: moq_net::origin::Consumer) -> Self { self.handshake = self.handshake.with_publisher(publish); self diff --git a/rs/moq-relay/src/settings.rs b/rs/moq-relay/src/settings.rs index 25ad57508b..340a481162 100644 --- a/rs/moq-relay/src/settings.rs +++ b/rs/moq-relay/src/settings.rs @@ -97,6 +97,9 @@ struct Cluster { #[usage(env = "MOQ_CLUSTER_TIER", cli("--cluster-tier"))] tier: Option, + #[usage(env = "MOQ_CLUSTER_FAIR", cli("--cluster-fair"))] + fair: Option, + #[cfg(feature = "cluster-lan")] #[usage(flatten)] lan: ClusterLan, diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index e391b6eafe..151063f466 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -808,7 +808,8 @@ async fn serve_request( } let mut request = request .with_stats(stats.clone()) - .with_limits(serve.cluster.limits(&lease)); + .with_limits(serve.cluster.limits(&lease)) + .with_schedule(serve.cluster.schedule(&lease)); if let Some(subscriber) = subscriber { request = request.with_publisher(subscriber); } diff --git a/rs/moq-room/src/chat.rs b/rs/moq-room/src/chat.rs index 22ebb790c6..214e27cd08 100644 --- a/rs/moq-room/src/chat.rs +++ b/rs/moq-room/src/chat.rs @@ -14,7 +14,7 @@ use std::{ /// Name of the track carrying the chat window. pub const TRACK: &str = "chat"; /// Delivery priority, below audio and video. -pub const PRIORITY: u8 = 10; +pub const PRIORITY: i8 = 10; /// How long a published message stays in the window. pub const HISTORY: Duration = Duration::from_secs(10); /// A message entering, leaving, or missed from the window. diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 446c9f9171..35ff2a73ec 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -210,6 +210,12 @@ impl Client { self } + /// Share each session's bandwidth between broadcasts; see [`moq_net::Client::with_schedule`]. + pub fn with_schedule(mut self, schedule: moq_net::session::Schedule) -> Self { + self.moq = self.moq.with_schedule(schedule); + self + } + /// Attach a per-connection [`moq_net::stats::Session`] context to all sessions /// opened by this client. pub fn with_stats(mut self, stats: moq_net::stats::Session) -> Self { diff --git a/rs/moq-tokio/src/quic/driver/connection.rs b/rs/moq-tokio/src/quic/driver/connection.rs index 6913eb8382..ba1f00ffcf 100644 --- a/rs/moq-tokio/src/quic/driver/connection.rs +++ b/rs/moq-tokio/src/quic/driver/connection.rs @@ -28,7 +28,7 @@ use crate::quic::driver::{ mutex::Mutex, recv_stream::RecvStream, runtime::UdpSender, - send_group::{self, SendGroup, SendOptions}, + send_group::{self, SendDomain, SendGroup, SendOptions}, send_stream::SendStream, udp_transmit, }; @@ -342,12 +342,20 @@ impl Connection { } } - /// Open a send group at `priority`, for streams and datagrams to join with [`SendOptions`] - /// or [`SendStream::set_group`]. + /// Open a send domain, which shares bytes equally with the connection's other domains. + pub fn send_domain(&self) -> SendDomain { + SendDomain::new(self.0.0.clone()) + } + + /// Open a send group at `priority` in `domain`, or the connection's default domain, for + /// streams and datagrams to join with [`SendOptions`] or [`SendStream::set_group`]. + /// + /// # Panics + /// - when `domain` belongs to another connection /// /// [`SendStream::set_group`]: crate::quic::driver::SendStream::set_group - pub fn send_group(&self, priority: i64) -> SendGroup { - SendGroup::new(self.0.0.clone(), priority) + pub fn send_group(&self, domain: Option<&SendDomain>, priority: i64) -> SendGroup { + SendGroup::new(self.0.0.clone(), domain, priority) } /// Initiate a new outgoing bidirectional stream, its sending half scheduled by `options`. diff --git a/rs/moq-tokio/src/quic/driver/mod.rs b/rs/moq-tokio/src/quic/driver/mod.rs index 5acc245510..02fc3051ee 100644 --- a/rs/moq-tokio/src/quic/driver/mod.rs +++ b/rs/moq-tokio/src/quic/driver/mod.rs @@ -80,7 +80,7 @@ pub(crate) use crate::quic::driver::runtime::send_failed; #[cfg(test)] pub(crate) use crate::quic::driver::runtime::wrap_udp_socket; pub use crate::quic::driver::runtime::{AsyncUdpSocket, UdpSender}; -pub use crate::quic::driver::send_group::{SendGroup, SendOptions}; +pub use crate::quic::driver::send_group::{SendDomain, SendGroup, SendOptions}; pub use crate::quic::driver::send_stream::{SendStream, StoppedError, WriteError}; #[cfg(test)] diff --git a/rs/moq-tokio/src/quic/driver/send_group.rs b/rs/moq-tokio/src/quic/driver/send_group.rs index 5270cdd8de..5ba873280a 100644 --- a/rs/moq-tokio/src/quic/driver/send_group.rs +++ b/rs/moq-tokio/src/quic/driver/send_group.rs @@ -4,9 +4,10 @@ use crate::quic::driver::connection::ConnectionInner; /// A send group on a [`Connection`](crate::quic::driver::Connection) /// -/// Streams that [join](crate::quic::driver::SendStream::set_group) a group share bytes with -/// the connection's other groups: a higher priority group preempts every lower one, and groups -/// of equal priority get an equal share of bytes however many streams each has queued. A +/// Streams that [join](crate::quic::driver::SendStream::set_group) a group share their +/// [`SendDomain`]'s bytes with its other groups: a higher priority group preempts every lower +/// one, and groups of equal priority get an equal share of bytes however many streams each has +/// queued. A /// stream's own priority orders it only against the group's other streams, and a datagram sent /// in the group is ordered among them by its priority. Streams and datagrams outside any group /// go before every group. See [`moq_quic::SendGroups`]. @@ -34,13 +35,13 @@ struct Inner { } impl SendGroup { - pub(crate) fn new(conn: Arc, priority: i64) -> Self { + pub(crate) fn new(conn: Arc, domain: Option<&SendDomain>, priority: i64) -> Self { let group = conn .state .lock("SendGroup::new") .inner .send_groups() - .open(priority); + .open(domain.map(SendDomain::id), priority); Self(Arc::new(Inner { conn, group: Some(group), @@ -73,3 +74,49 @@ impl Drop for Inner { } } } + +/// A domain of send groups on a [`Connection`](crate::quic::driver::Connection) +/// +/// Domains share the bytes the ungrouped streams leave equally, however many groups each +/// holds, and the groups opened without one form the connection's default domain. A group's +/// priority only orders it against the other groups of its domain. See +/// [`moq_quic::SendGroups::open_domain`]. +/// +/// Clones share one domain, released once the last clone is dropped and its groups are. +#[derive(Debug, Clone)] +pub struct SendDomain(Arc); + +#[derive(Debug)] +struct DomainInner { + conn: Arc, + domain: Option, +} + +impl SendDomain { + pub(crate) fn new(conn: Arc) -> Self { + let domain = conn + .state + .lock("SendDomain::new") + .inner + .send_groups() + .open_domain(); + Self(Arc::new(DomainInner { + conn, + domain: Some(domain), + })) + } + + /// The domain as the connection knows it, which refuses it on any other connection. + fn id(&self) -> &moq_quic::SendDomain { + self.0.domain.as_ref().expect("only taken on drop") + } +} + +impl Drop for DomainInner { + fn drop(&mut self) { + if let Some(domain) = self.domain.take() { + let mut conn = self.conn.state.lock("SendDomain::drop"); + conn.inner.send_groups().close_domain(domain); + } + } +} diff --git a/rs/moq-tokio/src/quic/web/session.rs b/rs/moq-tokio/src/quic/web/session.rs index b65d732209..24d13c4302 100644 --- a/rs/moq-tokio/src/quic/web/session.rs +++ b/rs/moq-tokio/src/quic/web/session.rs @@ -362,13 +362,22 @@ impl Session { Ok(()) } - /// Open a send group at `priority`, for streams and datagrams to join with [`SendOptions`] - /// or [`SendStream::set_group`]. + /// Open a send domain. See [`crate::quic::driver::SendDomain`]. + pub fn send_domain(&self) -> crate::quic::driver::SendDomain { + self.conn.send_domain() + } + + /// Open a send group at `priority` in `domain`, or the connection's default domain, for + /// streams and datagrams to join with [`SendOptions`] or [`SendStream::set_group`]. /// /// See [`crate::quic::driver::SendGroup`]. The HTTP/3 streams stay outside every group, /// so they go first. - pub fn send_group(&self, priority: i64) -> crate::quic::driver::SendGroup { - self.conn.send_group(priority) + pub fn send_group( + &self, + domain: Option<&crate::quic::driver::SendDomain>, + priority: i64, + ) -> crate::quic::driver::SendGroup { + self.conn.send_group(domain, priority) } /// Computes the maximum size of datagrams that may be passed to diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index b84e07ee32..20bc44904a 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1779,6 +1779,13 @@ impl Request { self } + /// Share the session's bandwidth between broadcasts, after authorizing the peer and + /// before answering SETUP; see [`moq_net::session::Schedule`]. + pub fn with_schedule(mut self, schedule: moq_net::session::Schedule) -> Self { + self.kind = request_map!(self.kind, request => request.with_schedule(schedule)); + self + } + /// The QUIC connection under this request, to change its limits before answering /// SETUP. `None` on a transport moq-quic does not drive (qmux streams, iroh). #[cfg(feature = "quic")] diff --git a/rs/moq-tokio/src/transport.rs b/rs/moq-tokio/src/transport.rs index 5f60ad295b..971f33bdb2 100644 --- a/rs/moq-tokio/src/transport.rs +++ b/rs/moq-tokio/src/transport.rs @@ -878,8 +878,16 @@ impl Grouped for crate::quic::web::Session { Some(crate::quic::driver::Connection::wire(self)) } - fn send_group(&self, priority: i64) -> Option { - Some(Self::send_group(self, priority)) + fn send_domain(&self) -> Option { + Some(Self::send_domain(self)) + } + + fn send_group( + &self, + domain: Option<&crate::quic::driver::SendDomain>, + priority: i64, + ) -> Option { + Some(Self::send_group(self, domain, priority)) } fn send_datagram( @@ -911,6 +919,8 @@ impl GroupedSend for crate::quic::web::SendStream { #[cfg(feature = "quic")] impl moq_net::transport::poll::SendGroup for crate::quic::driver::SendGroup { + type Domain = crate::quic::driver::SendDomain; + fn set_priority(&mut self, priority: i64) { Self::set_priority(self, priority) } @@ -1030,6 +1040,8 @@ mod tests { struct FakeGroup(u32); impl moq_net::transport::poll::SendGroup for FakeGroup { + type Domain = moq_net::transport::poll::Ungrouped; + fn set_priority(&mut self, _: i64) {} } diff --git a/rs/moq-tokio/src/transport/grouped.rs b/rs/moq-tokio/src/transport/grouped.rs index a3b0bfc9f3..915ac0b935 100644 --- a/rs/moq-tokio/src/transport/grouped.rs +++ b/rs/moq-tokio/src/transport/grouped.rs @@ -6,6 +6,9 @@ use moq_net::transport::poll::SendOptions; +/// A backend session's send domain. +pub type Domain = <<::SendStream as GroupedSend>::Group as moq_net::transport::poll::SendGroup>::Domain; + /// A backend session whose send streams may join send groups, as native QUIC's do. /// /// The async interface has no groups or wire counters, so moq-net's @@ -18,9 +21,14 @@ pub trait Grouped: web_transport_trait::Session { None } - /// Open a send group at `priority`, or `None` when the backend has none. - fn send_group(&self, priority: i64) -> Option<::Group> { - let _ = priority; + /// Open a send domain, or `None` when the backend has none. + fn send_domain(&self) -> Option> { + None + } + + /// Open a send group at `priority` in `domain`, or `None` when the backend has none. + fn send_group(&self, domain: Option<&Domain>, priority: i64) -> Option<::Group> { + let _ = (domain, priority); None } @@ -38,7 +46,7 @@ pub trait Grouped: web_transport_trait::Session { /// A backend send stream that may join its [`Grouped`] session's send groups. pub trait GroupedSend: web_transport_trait::SendStream { /// The backend's send group, [`Ungrouped`](moq_net::transport::poll::Ungrouped) when it has none. - type Group: moq_net::transport::poll::SendGroup + Send + Sync; + type Group: moq_net::transport::poll::SendGroup + Send + Sync; /// Move the stream into `group`. fn set_group(&mut self, group: &Self::Group); diff --git a/rs/moq-tokio/src/transport/owned.rs b/rs/moq-tokio/src/transport/owned.rs index eec916f342..238504ad4f 100644 --- a/rs/moq-tokio/src/transport/owned.rs +++ b/rs/moq-tokio/src/transport/owned.rs @@ -81,8 +81,15 @@ impl> poll::Session }) .map_err(Error) } - fn send_group(&mut self, priority: i64) -> Option<::Group> { - self.session.send_group(priority) + fn send_domain(&mut self) -> Option> { + self.session.send_domain() + } + fn send_group( + &mut self, + domain: Option<&poll::DomainOf>, + priority: i64, + ) -> Option<::Group> { + self.session.send_group(domain, priority) } fn send_datagram( &mut self, diff --git a/rs/moq-uring/src/quic/driver/connection.rs b/rs/moq-uring/src/quic/driver/connection.rs index 1e08132aa2..5c0d2a0600 100644 --- a/rs/moq-uring/src/quic/driver/connection.rs +++ b/rs/moq-uring/src/quic/driver/connection.rs @@ -302,11 +302,19 @@ impl Connection { &self.shared.owner } - /// Open a send group at `priority`, for streams to join with - /// [`SendStream::set_group`](super::SendStream::set_group) and datagrams with - /// [`SendOptions`](super::SendOptions). - pub fn send_group(&self, priority: i64) -> super::SendGroup { - super::SendGroup::new(self.shared.clone(), priority) + /// Open a send domain, which shares bytes equally with the connection's other domains. + pub fn send_domain(&self) -> super::SendDomain { + super::SendDomain::new(self.shared.clone()) + } + + /// Open a send group at `priority` in `domain`, or the connection's default domain, for + /// streams to join with [`SendStream::set_group`](super::SendStream::set_group) and + /// datagrams with [`SendOptions`](super::SendOptions). + /// + /// # Panics + /// - when `domain` belongs to another connection + pub fn send_group(&self, domain: Option<&super::SendDomain>, priority: i64) -> super::SendGroup { + super::SendGroup::new(self.shared.clone(), domain, priority) } /// Send a datagram scheduled by `options`, like one of its group's streams at its priority, diff --git a/rs/moq-uring/src/quic/driver/group.rs b/rs/moq-uring/src/quic/driver/group.rs index 5486108632..28945118d2 100644 --- a/rs/moq-uring/src/quic/driver/group.rs +++ b/rs/moq-uring/src/quic/driver/group.rs @@ -6,8 +6,8 @@ use super::Shared; /// A send group on a [`Connection`](super::Connection). /// -/// Streams that [join](super::SendStream::set_group) a group share bytes with the -/// connection's other groups: a higher priority group preempts every lower one, and groups of +/// Streams that [join](super::SendStream::set_group) a group share their [`SendDomain`]'s +/// bytes with its other groups: a higher priority group preempts every lower one, and groups of /// equal priority get an equal share of bytes however many streams each has queued. A stream's /// own priority orders it only against the group's other streams, and a datagram sent in the /// group is ordered among them by its priority. Streams and datagrams outside any group go @@ -35,8 +35,12 @@ struct Inner { } impl SendGroup { - pub(crate) fn new(shared: Shared, priority: i64) -> Self { - let group = shared.conn.borrow_mut().send_groups().open(priority); + pub(crate) fn new(shared: Shared, domain: Option<&SendDomain>, priority: i64) -> Self { + let group = shared + .conn + .borrow_mut() + .send_groups() + .open(domain.map(SendDomain::id), priority); Self(Rc::new(Inner { shared, group: Some(group), @@ -77,3 +81,48 @@ impl std::fmt::Debug for SendGroup { f.debug_struct("SendGroup").finish_non_exhaustive() } } + +/// A domain of send groups on a [`Connection`](super::Connection). +/// +/// Domains share the bytes the ungrouped streams leave equally, however many groups each +/// holds, and the groups opened without one form the connection's default domain. A group's +/// priority only orders it against the other groups of its domain. See +/// [`moq_quic::SendGroups::open_domain`]. +/// +/// Clones share one domain, released once the last clone is dropped and its groups are. +#[derive(Clone)] +pub struct SendDomain(Rc); + +struct DomainInner { + shared: Shared, + domain: Option, +} + +impl SendDomain { + pub(crate) fn new(shared: Shared) -> Self { + let domain = shared.conn.borrow_mut().send_groups().open_domain(); + Self(Rc::new(DomainInner { + shared, + domain: Some(domain), + })) + } + + /// The domain as the connection knows it, which refuses it on any other connection. + fn id(&self) -> &moq_quic::SendDomain { + self.0.domain.as_ref().expect("only taken on drop") + } +} + +impl Drop for DomainInner { + fn drop(&mut self) { + if let Some(domain) = self.domain.take() { + self.shared.conn.borrow_mut().send_groups().close_domain(domain); + } + } +} + +impl std::fmt::Debug for SendDomain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SendDomain").finish_non_exhaustive() + } +} diff --git a/rs/moq-uring/src/quic/driver/mod.rs b/rs/moq-uring/src/quic/driver/mod.rs index a5270c9699..30a1b154e9 100644 --- a/rs/moq-uring/src/quic/driver/mod.rs +++ b/rs/moq-uring/src/quic/driver/mod.rs @@ -16,7 +16,7 @@ mod stream; pub use connection::Connection; pub use endpoint::Endpoint; -pub use group::{SendGroup, SendOptions}; +pub use group::{SendDomain, SendGroup, SendOptions}; pub use stream::{Credit, RecvStream, SendStream}; pub(crate) use connection::{End, Shared}; diff --git a/rs/moq-uring/src/quic/mod.rs b/rs/moq-uring/src/quic/mod.rs index 0b918d1492..6dafc832f6 100644 --- a/rs/moq-uring/src/quic/mod.rs +++ b/rs/moq-uring/src/quic/mod.rs @@ -30,7 +30,7 @@ pub mod web; mod driver; -pub use driver::{Connection, Credit, RecvStream, SendGroup, SendOptions, SendStream}; +pub use driver::{Connection, Credit, RecvStream, SendDomain, SendGroup, SendOptions, SendStream}; pub use endpoint::Endpoint; /// The flow-control limits a live connection can change; see [`Connection::set_limits`]. pub use moq_quic::{Limits, LimitsError}; diff --git a/rs/moq-uring/src/quic/web.rs b/rs/moq-uring/src/quic/web.rs index e644043da0..221cd9a88c 100644 --- a/rs/moq-uring/src/quic/web.rs +++ b/rs/moq-uring/src/quic/web.rs @@ -450,11 +450,16 @@ impl Session { self.conn.wire() } - /// Open a send group at `priority`, for streams to join with - /// [`SendStream::set_group`]. The HTTP/3 streams stay outside every + /// Open a send domain. See [`super::SendDomain`]. + pub fn send_domain(&self) -> super::SendDomain { + self.conn.send_domain() + } + + /// Open a send group at `priority` in `domain`, or the connection's default domain, for + /// streams to join with [`SendStream::set_group`]. The HTTP/3 streams stay outside every /// group, so they go first. - pub fn send_group(&self, priority: i64) -> super::SendGroup { - self.conn.send_group(priority) + pub fn send_group(&self, domain: Option<&super::SendDomain>, priority: i64) -> super::SendGroup { + self.conn.send_group(domain, priority) } /// Send a datagram scheduled by `options`, like one of its group's streams. diff --git a/rs/moq-uring/src/transport.rs b/rs/moq-uring/src/transport.rs index 12efe45010..4bb5bc707e 100644 --- a/rs/moq-uring/src/transport.rs +++ b/rs/moq-uring/src/transport.rs @@ -15,8 +15,12 @@ impl Grouped for quic::web::Session { Some(quic::web::Session::wire(self)) } - fn send_group(&mut self, priority: i64) -> Option { - Some(quic::web::Session::send_group(self, priority)) + fn send_domain(&mut self) -> Option { + Some(quic::web::Session::send_domain(self)) + } + + fn send_group(&mut self, domain: Option<&quic::SendDomain>, priority: i64) -> Option { + Some(quic::web::Session::send_group(self, domain, priority)) } fn send_datagram( @@ -47,6 +51,8 @@ impl GroupedSend for quic::web::SendStream { #[cfg(feature = "quic")] impl moq_net::transport::poll::SendGroup for quic::SendGroup { + type Domain = quic::SendDomain; + fn set_priority(&mut self, priority: i64) { quic::SendGroup::set_priority(self, priority) } diff --git a/rs/moq-uring/src/transport/adapter.rs b/rs/moq-uring/src/transport/adapter.rs index b2e2fa32c1..ac3cf61a4e 100644 --- a/rs/moq-uring/src/transport/adapter.rs +++ b/rs/moq-uring/src/transport/adapter.rs @@ -47,6 +47,9 @@ impl moq::Stats for Stats { self.0.estimated_send_rate() } } +/// A backend session's send domain. +pub type Domain = <<::SendStream as GroupedSend>::Group as poll::SendGroup>::Domain; + /// A backend session whose send streams may join send groups, as native QUIC's do. /// /// `pub` only because `pub(crate)` is refused (E0446): the adapter's `type Group` projects @@ -62,9 +65,18 @@ pub trait Grouped: backend::Session { None } - /// Open a send group at `priority`, or `None` when the backend has none. - fn send_group(&mut self, priority: i64) -> Option<::Group> { - let _ = priority; + /// Open a send domain, or `None` when the backend has none. + fn send_domain(&mut self) -> Option> { + None + } + + /// Open a send group at `priority` in `domain`, or `None` when the backend has none. + fn send_group( + &mut self, + domain: Option<&Domain>, + priority: i64, + ) -> Option<::Group> { + let _ = (domain, priority); None } @@ -141,8 +153,15 @@ impl + Clone + 'static> pol .map_err(Error) .map(|res| res.map(|(send, recv)| (SendStream(send).scheduled(options), RecvStream(recv)))) } - fn send_group(&mut self, priority: i64) -> Option<::Group> { - Grouped::send_group(&mut self.0, priority) + fn send_domain(&mut self) -> Option> { + Grouped::send_domain(&mut self.0) + } + fn send_group( + &mut self, + domain: Option<&poll::DomainOf>, + priority: i64, + ) -> Option<::Group> { + Grouped::send_group(&mut self.0, domain, priority) } fn send_datagram( &mut self, diff --git a/rs/moq-uring/src/transport/local.rs b/rs/moq-uring/src/transport/local.rs index 7c42bcb9b7..340fc9c976 100644 --- a/rs/moq-uring/src/transport/local.rs +++ b/rs/moq-uring/src/transport/local.rs @@ -63,8 +63,16 @@ impl poll::Session for Local { .map_ok(|(send, recv)| (Local::new(send), Local::new(recv))) } - fn send_group(&mut self, priority: i64) -> Option::Group>> { - self.0.send_group(priority).map(Local::new) + fn send_domain(&mut self) -> Option> { + self.0.send_domain().map(Local::new) + } + + fn send_group( + &mut self, + domain: Option<&poll::DomainOf>, + priority: i64, + ) -> Option::Group>> { + self.0.send_group(domain.map(|domain| &*domain.0), priority).map(Local::new) } fn send_datagram( @@ -166,6 +174,8 @@ impl poll::RecvStream for Local { } impl poll::SendGroup for Local { + type Domain = Local; + fn set_priority(&mut self, priority: i64) { self.0.set_priority(priority) } diff --git a/rs/moq-wasm/src/transport/adapter.rs b/rs/moq-wasm/src/transport/adapter.rs index 7b6a08e2e1..8028b140ed 100644 --- a/rs/moq-wasm/src/transport/adapter.rs +++ b/rs/moq-wasm/src/transport/adapter.rs @@ -85,7 +85,10 @@ impl + Clone + 'st .map_err(Error) .map(|res| res.map(|(send, recv)| (SendStream(send).scheduled(options), RecvStream(recv)))) } - fn send_group(&mut self, _: i64) -> Option { + fn send_domain(&mut self) -> Option { + None + } + fn send_group(&mut self, _: Option<&poll::Ungrouped>, _: i64) -> Option { None } /// The backend cannot rank a datagram against a stream, so `options` are ignored, and a From a6613c70d44d4addb4f4135a7507450ac588ddfe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 19:44:25 -0700 Subject: [PATCH 03/11] feat: signed track priorities and per-broadcast relay scheduling Every priority is an i8, higher first, with 0 as the unset default. The wire stays one byte: moq-lite carries p + 128 and IETF carries 127 - p. The publisher priority breaks subscriber-priority ties in Rust and JS. A session scheduled per broadcast opens one moq-quic send domain per broadcast, so broadcasts share the link byte-fairly and the publisher's track priority orders streams inside each. The relay enables it on cluster links with cluster.fair, falling back to strict ordering on transports without send domains. Bindings, wrappers, docs, the moq-lite draft, and MIGRATE entries follow. Co-Authored-By: Claude Opus 5.5 --- dart/moq/MIGRATE.md | 6 ++ dart/moq_ffi/lib/src/moq.dart | 78 +++++++++---------- dart/moq_ffi/lib/src/uniffi_runtime.dart | 2 + doc/bin/relay/cluster.md | 16 ++++ doc/bin/relay/config.md | 1 + doc/concept/moq-lite.md | 2 +- doc/concept/standard.md | 2 +- doc/setup/upgrade.md | 8 ++ drafts/draft-lcurley-moq-lite.md | 10 ++- go/wrapper/MIGRATE.md | 6 ++ go/wrapper/json/json_test.go | 2 +- go/wrapper/moq_test.go | 2 +- go/wrapper/publish.go | 2 +- go/wrapper/records.go | 6 +- js/hang/src/catalog/priority.ts | 5 +- js/net/MIGRATE.md | 7 ++ js/net/src/ietf/object.ts | 3 +- js/net/src/ietf/priority.test.ts | 29 ++++--- js/net/src/ietf/priority.ts | 21 ++++- js/net/src/ietf/properties.ts | 2 +- js/net/src/ietf/publisher.test.ts | 6 +- js/net/src/ietf/publisher.ts | 2 +- js/net/src/ietf/subscribe.ts | 5 +- js/net/src/ietf/subscriber.test.ts | 20 ++--- js/net/src/ietf/subscriber.ts | 4 +- js/net/src/lite/fetch.ts | 5 +- js/net/src/lite/priority.ts | 19 +++++ js/net/src/lite/publisher.ts | 11 ++- js/net/src/lite/subscribe.test.ts | 4 +- js/net/src/lite/subscribe.ts | 21 ++--- js/net/src/lite/track.test.ts | 10 +-- js/net/src/lite/track.ts | 13 ++-- js/net/src/priority.test.ts | 32 +++++--- js/net/src/priority.ts | 52 ++++++++----- js/net/src/subscribe_priority.test.ts | 4 +- js/net/src/track.test.ts | 15 ++-- js/net/src/track.ts | 24 +++--- kt/moq/MIGRATE.md | 6 ++ .../kotlin/dev/moq/SmokeTest.kt | 4 +- py/moq-rs/MIGRATE.md | 6 ++ py/moq-rs/moq/_records.py | 6 +- py/moq-rs/tests/test_local.py | 2 +- rs/hang/MIGRATE.md | 5 ++ rs/hang/src/catalog/priority.rs | 6 +- rs/moq-archive/MIGRATE.md | 5 ++ rs/moq-c/MIGRATE.md | 6 ++ rs/moq-ffi/MIGRATE.md | 6 ++ rs/moq-mux/MIGRATE.md | 4 + rs/moq-net/MIGRATE.md | 15 +++- rs/moq-net/src/ietf/subscriber.rs | 6 +- rs/moq-net/src/lib.rs | 4 +- rs/moq-net/src/lite/publisher.rs | 60 +++++++++----- rs/moq-net/src/lite/session.rs | 1 - rs/moq-net/src/model/group.rs | 1 + rs/moq-net/src/model/track.rs | 2 +- rs/moq-net/src/scheduler.rs | 2 +- rs/moq-net/src/transport.rs | 3 +- rs/moq-quic/src/connection/mod.rs | 5 +- rs/moq-relay/src/cluster.rs | 5 +- rs/moq-room/MIGRATE.md | 4 + rs/moq-tokio/src/quic/driver/send_group.rs | 6 +- rs/moq-tokio/src/transport/grouped.rs | 6 +- rs/moq-uring/src/transport/local.rs | 4 +- swift/MIGRATE.md | 6 ++ swift/Sources/Moq/Dynamic.swift | 2 +- 65 files changed, 444 insertions(+), 201 deletions(-) create mode 100644 js/net/src/lite/priority.ts diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 8154fd68df..5b8b6e7b08 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed + +`TrackInfo.priority`, `Subscription.priority`, and `FetchGroupOptions.priority` take `-128..=127`, higher first, and 0 is the unset midpoint. +`TrackInfo.priority` defaulted to 127 and now defaults to 0. +To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. + ### JSON consumers return `Value` (#5677) `SnapshotConsumer.next()` and `StreamConsumer.next()` return `Value` (`value`, `timestampUs`) instead of a string; `timestampUs` is null when untimed. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 8c807bd0d3..0cd53f9523 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -510,7 +510,7 @@ class FfiConverterMoqFetchGroupOptions { static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final priority_lifted = FfiConverterUInt8.read( + final priority_lifted = FfiConverterInt8.read( Uint8List.view(buf.buffer, new_offset), ); final priority = priority_lifted.value; @@ -528,7 +528,7 @@ class FfiConverterMoqFetchGroupOptions { static RustBuffer lower(MoqFetchGroupOptions value) { final total_length = - FfiConverterUInt8.allocationSize(value.priority) + + FfiConverterInt8.allocationSize(value.priority) + FfiConverterOptionalUInt64.allocationSize(value.maxDelayUs) + 0; final buf = Uint8List(total_length); @@ -538,7 +538,7 @@ class FfiConverterMoqFetchGroupOptions { static int write(MoqFetchGroupOptions value, Uint8List buf) { int new_offset = buf.offsetInBytes; - new_offset += FfiConverterUInt8.write( + new_offset += FfiConverterInt8.write( value.priority, Uint8List.view(buf.buffer, new_offset), ); @@ -550,7 +550,7 @@ class FfiConverterMoqFetchGroupOptions { } static int allocationSize(MoqFetchGroupOptions value) { - return FfiConverterUInt8.allocationSize(value.priority) + + return FfiConverterInt8.allocationSize(value.priority) + FfiConverterOptionalUInt64.allocationSize(value.maxDelayUs) + 0; } @@ -635,7 +635,7 @@ class FfiConverterMoqSubscription { static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final priority_lifted = FfiConverterUInt8.read( + final priority_lifted = FfiConverterInt8.read( Uint8List.view(buf.buffer, new_offset), ); final priority = priority_lifted.value; @@ -668,7 +668,7 @@ class FfiConverterMoqSubscription { static RustBuffer lower(MoqSubscription value) { final total_length = - FfiConverterUInt8.allocationSize(value.priority) + + FfiConverterInt8.allocationSize(value.priority) + FfiConverterUInt64.allocationSize(value.maxDelayUs) + FfiConverterOptionalSequenceMoqRange.allocationSize(value.ranges) + FfiConverterOptionalMoqOrder.allocationSize(value.order) + @@ -680,7 +680,7 @@ class FfiConverterMoqSubscription { static int write(MoqSubscription value, Uint8List buf) { int new_offset = buf.offsetInBytes; - new_offset += FfiConverterUInt8.write( + new_offset += FfiConverterInt8.write( value.priority, Uint8List.view(buf.buffer, new_offset), ); @@ -700,7 +700,7 @@ class FfiConverterMoqSubscription { } static int allocationSize(MoqSubscription value) { - return FfiConverterUInt8.allocationSize(value.priority) + + return FfiConverterInt8.allocationSize(value.priority) + FfiConverterUInt64.allocationSize(value.maxDelayUs) + FfiConverterOptionalSequenceMoqRange.allocationSize(value.ranges) + FfiConverterOptionalMoqOrder.allocationSize(value.order) + @@ -2641,7 +2641,7 @@ class MoqTrackInfo { final int? refresh; final int? timescale; MoqTrackInfo({ - this.priority = 127, + this.priority = 0, this.maxAgeUs = null, this.refresh = null, this.timescale = null, @@ -2655,7 +2655,7 @@ class FfiConverterMoqTrackInfo { static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final priority_lifted = FfiConverterUInt8.read( + final priority_lifted = FfiConverterInt8.read( Uint8List.view(buf.buffer, new_offset), ); final priority = priority_lifted.value; @@ -2688,7 +2688,7 @@ class FfiConverterMoqTrackInfo { static RustBuffer lower(MoqTrackInfo value) { final total_length = - FfiConverterUInt8.allocationSize(value.priority) + + FfiConverterInt8.allocationSize(value.priority) + FfiConverterOptionalUInt64.allocationSize(value.maxAgeUs) + FfiConverterOptionalUInt32.allocationSize(value.refresh) + FfiConverterOptionalUInt64.allocationSize(value.timescale) + @@ -2700,7 +2700,7 @@ class FfiConverterMoqTrackInfo { static int write(MoqTrackInfo value, Uint8List buf) { int new_offset = buf.offsetInBytes; - new_offset += FfiConverterUInt8.write( + new_offset += FfiConverterInt8.write( value.priority, Uint8List.view(buf.buffer, new_offset), ); @@ -2720,7 +2720,7 @@ class FfiConverterMoqTrackInfo { } static int allocationSize(MoqTrackInfo value) { - return FfiConverterUInt8.allocationSize(value.priority) + + return FfiConverterInt8.allocationSize(value.priority) + FfiConverterOptionalUInt64.allocationSize(value.maxAgeUs) + FfiConverterOptionalUInt32.allocationSize(value.refresh) + FfiConverterOptionalUInt64.allocationSize(value.timescale) + @@ -11207,7 +11207,7 @@ class MoqGroupRequest implements MoqGroupRequestInterface { uniffiClonePointer(), status, ), - FfiConverterUInt8.lift, + FfiConverterInt8.lift, null, ); } @@ -12759,6 +12759,29 @@ class FfiConverterDouble64 { } } +class FfiConverterInt8 { + static int lift(int value) => value; + static LiftRetVal read(Uint8List buf) { + return LiftRetVal(buf.buffer.asByteData(buf.offsetInBytes).getInt8(0), 1); + } + + static int lower(int value) { + if (value < -128 || value > 127) { + throw ArgumentError("Value out of range for i8: " + value.toString()); + } + return value; + } + + static int allocationSize([int value = 0]) { + return 1; + } + + static int write(int value, Uint8List buf) { + buf.buffer.asByteData(buf.offsetInBytes).setInt8(0, lower(value)); + return 1; + } +} + class FfiConverterMapStringToMoqAudio { static Map lift(RustBuffer buf) { return FfiConverterMapStringToMoqAudio.read(buf.asUint8List()).value; @@ -14889,29 +14912,6 @@ class FfiConverterUInt64 { } } -class FfiConverterUInt8 { - static int lift(int value) => value; - static LiftRetVal read(Uint8List buf) { - return LiftRetVal(buf.buffer.asByteData(buf.offsetInBytes).getUint8(0), 1); - } - - static int lower(int value) { - if (value < 0 || value > 255) { - throw ArgumentError("Value out of range for u8: " + value.toString()); - } - return value; - } - - static int allocationSize([int value = 0]) { - return 1; - } - - static int write(int value, Uint8List buf) { - buf.buffer.asByteData(buf.offsetInBytes).setUint8(0, lower(value)); - return 1; - } -} - class FfiConverterUint8List { static Uint8List lift(RustBuffer value) { return FfiConverterUint8List.read(value.asUint8List()).value; @@ -16719,7 +16719,7 @@ external Pointer uniffi_moq_ffi_fn_method_moqgrouprequest_demand( Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) external int uniffi_moq_ffi_fn_method_moqgrouprequest_priority( @@ -18825,7 +18825,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqgrouprequest_demand() != 31288) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_priority() != 1745) { + if (uniffi_moq_ffi_checksum_method_moqgrouprequest_priority() != 15373) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqgrouprequest_sequence() != 29523) { diff --git a/dart/moq_ffi/lib/src/uniffi_runtime.dart b/dart/moq_ffi/lib/src/uniffi_runtime.dart index ab87a64ed3..1eefdf6293 100644 --- a/dart/moq_ffi/lib/src/uniffi_runtime.dart +++ b/dart/moq_ffi/lib/src/uniffi_runtime.dart @@ -8,6 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; + import "package:ffi/ffi.dart"; class UniffiInternalError implements Exception { @@ -247,6 +248,7 @@ mixin FfiConverterPrimitive on FfiConverter { @override T lower(T value) => value; } + Uint8List createUint8ListFromInt(int value) { int length = value.bitLength ~/ 8 + 1; if (length != 4 && length != 8) { diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index 762ea095e5..81a8c32290 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -167,6 +167,22 @@ the maximum so new subscriptions go elsewhere while existing ones finish. A route's price is the publisher's production cost plus the link costs it crosses. A live publisher seeds 0, and caching never changes it. +## Fair links + +A link between relays carries every tenant's broadcasts, and by default it +sends the highest subscriber priority first, so one broadcast's viewers can +starve another's when the link congests. `fair = true` gives each broadcast an +equal share of the link instead, however many tracks or viewers it has, and +orders a broadcast's tracks by the publisher's priority alone. + +```toml +[cluster] +fair = true +``` + +It applies to the links this relay serves over QUIC or WebTransport on +moq-lite. Links over WebSocket, iroh, or moq-transport stay strict. + ## LAN discovery On a LAN there may be no one to list. `[cluster.lan]` advertises this relay diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index f7cfa05133..721d56fafa 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -262,6 +262,7 @@ node = "https://us-west.example.com/" # This relay's own URL. connect_api = "https://api.example.com/peers" # Or fetch the peer list (JSON array of URLs and/or objects) live. token = "cluster.jwt" # JWT for dials without an inline ?jwt=. id = 12345 # Stable Hop ID across restarts. +# fair = true # Share peer links equally between broadcasts. [cluster.lan] # Find peers on the LAN over mDNS. enabled = true diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index edda6e031b..3d3a1eb874 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -226,7 +226,7 @@ Each subscription carries the knobs that decide behavior under congestion: | Knob | Effect | | --- | --- | -| **Priority** (0..255) | Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement. From a native Rust publisher over QUIC or WebTransport (iroh excepted), subscriptions at the same priority split bandwidth evenly by bytes, streams and datagrams alike, however many groups each has queued. A browser publisher and other transports without send groups are not covered. | +| **Priority** (-128..127, default 0) | Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement. The publisher's track priority breaks ties between subscriptions at one priority. From a native Rust publisher over QUIC or WebTransport (iroh excepted), subscriptions at the same priority split bandwidth evenly by bytes, streams and datagrams alike, however many groups each has queued. A browser publisher and other transports without send groups are not covered. | | **Order** | Which group to send first when several are pending. Newest first for live (the default), oldest first for catch-up. Needs moq-lite 07 or moq-transport; older moq-lite always sends newest first. | | **Max delay** | How long a non-latest group stays worth delivering before it is skipped. Zero means "live edge only", and raising it is also what asks for history. | | **Ranges** | Which groups to ask for: from a group, or a number of groups back from the latest, optionally up to an end. The default asks for everything max delay allows. Max delay still caps every range. | diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 59605edbf2..27abddcdd2 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -33,7 +33,7 @@ subset drops. What a peer actually observes against this implementation: - **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, `SUBSCRIBE_TRACKS`, and the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority, negotiated subscriber max age, and delivery timeouts; Rust also renews request tokens and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. On draft 18 and later a namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. - **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers see consecutive frames and normal datagram payloads; JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session; a relayed one the downstream draft cannot carry is dropped. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none (drafts 14 through 16), as for a plain frame; an immutable one that would change or vanish is refused. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped across the draft-19 boundary, or refused when immutable, because draft 19 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. Draft 14 allows them, but an END\_OF\_TRACK carrying properties ends the subscription as unsupported, since a track end has nowhere to keep them. A `FETCH` carries the END\_OF\_GROUP object on drafts 14 and 15; later drafts have no FETCH status, so their response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. - **Dynamic groups.** A track advertises `DYNAMIC_GROUPS` when its publisher sets a refresh ratio, and a track received with `DYNAMIC_GROUPS` gets a ratio of 0. A `NEW_GROUP_REQUEST` (draft 15 and later) counts as one more subscriber joining, which a `SUBSCRIBE` already is: it asks for a new group only when the newest group's bytes after its first frame exceed the ratio times the first frame. Joins coalesce until the asked-for group arrives. A Rust relay sends `NEW_GROUP_REQUEST` upstream, on the `SUBSCRIBE` or in a `REQUEST_UPDATE`, once a join wants a group past the newest it holds; a relay holding nothing yet asks for none. On a track without a ratio, an update carrying one ends the subscription with `UPDATE_FAILED`. On drafts 15 and 16, whose `TRACK_STATUS` answer has no room for it, a relay copy first learned from `TRACK_STATUS` (a fetch-only reader) does not advertise it to later subscribers. A moq-lite-07 hop (`moq-lite-07-wip`, opt-in) carries the same demand natively: the ratio in `TRACK_INFO` and the asked-for group in `SUBSCRIBE_UPDATE`. Earlier moq-lite versions carry neither, so a track received over one never refreshes. -- **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. A relay's upstream subscription carries the highest priority its subscribers ask for and updates it as they come and go; its group order stays as the `SUBSCRIBE` asked, since no draft lets an update change it. +- **Priority.** Higher is served first. This stack's priorities run from -128 to 127 with 0 unset, and go out on the IETF wire, which ranks lower first, as `127 - priority`: an unset priority is IETF 127, and an absent IETF priority is 0. A relay's upstream subscription carries the highest priority its subscribers ask for and updates it as they come and go; its group order stays as the `SUBSCRIBE` asked, since no draft lets an update change it. - **Size.** An object extension block larger than 64 KiB ends that subgroup stream. The session stays up. This cap is ours, not the draft's. Several project drafts extend the IETF wire without breaking it, since `SETUP` diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index e5163fe8bf..f507246f46 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -32,6 +32,14 @@ ignored; its guide says what replaces it. These land with the next breaking release. +- **The default priority byte moves again.** Priorities are signed in every + API, -128 to 127 with 0 unset, so nobody has to know that 127 is the middle + of a byte. The wire keeps one byte and one urgency on both protocols, so an + unset priority now goes out as 128 on moq-lite (was 127) and 127 on + moq-transport (was 128); the previous release moved it to match moxygen. + hang's media priorities now sit above that default rather than below it. + Mixed versions only differ in how an unset priority ranks against a set one, + so upgrade publishers and players in either order. - **Data-track breaks are an object with no bytes** (#5815). A hang data track (TS private PES, sections, `m2ts`, text) marks a discontinuity with a group of one empty object instead of a skipped group. Older consumers fail diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 583757c388..d4a16b5cc1 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -622,6 +622,11 @@ A publisher SHOULD order a [datagram](#datagrams) among the subscription's strea ### Priority The `Subscriber Priority` is scoped to the connection and MAY change over the life of the subscription via SUBSCRIBE_UPDATE. The `Publisher Priority` is fixed for the lifetime of the Track (see [TRACK_INFO](#track-info)) and SHOULD be used only to resolve conflicts or ties. +Both are a byte where higher is more urgent, and 128 is the midpoint an endpoint SHOULD use for a priority it was never given. + +A relay serving many broadcasts to another relay MAY scope priority to the broadcast instead of the connection. +It then shares the connection's bandwidth equally between the broadcasts it serves, however many subscriptions each carries, and orders the streams within each broadcast by `Publisher Priority` alone, ignoring the `Subscriber Priority` it receives and not forwarding it upstream. +This keeps one broadcast's subscribers from starving another's, at the cost of the subscribers' preferences across that hop. A conflict can occur when a relay tries to serve multiple downstream subscriptions from a single upstream subscription. The relay cannot pick any one subscriber's priority, so the upstream subscription SHOULD use the publisher priority instead of some combination of different subscriber priorities. @@ -1157,7 +1162,7 @@ A unique identifier chosen by the subscriber. A Subscribe ID MUST NOT be reused within the same session, even if the prior subscription has been closed. **Subscriber Priority**: -The priority of the subscription within the session, represented as a u8. +The priority of the subscription within the session, represented as a u8 with 128 as the midpoint. The publisher SHOULD transmit *higher* values first during congestion. See the [Prioritization](#prioritization) section for more information. @@ -1278,7 +1283,7 @@ This is what lets the properties live on their own stream, fetched once and cach Publisher properties fan *out* at a relay (one upstream subscription serving many downstreams), so a change would have to propagate everywhere; subscriber properties fan *in*, which the relay already merges, so they MAY change freely via SUBSCRIBE_UPDATE. **Publisher Priority**: -The publisher's priority for this Track, represented as a u8, used only to resolve ties between subscriptions of equal subscriber priority. +The publisher's priority for this Track, represented as a u8 with 128 as the midpoint, used only to resolve ties between subscriptions of equal subscriber priority. See the [Prioritization](#prioritization) section for more information. **Publisher Max Age**: @@ -1639,6 +1644,7 @@ The `Message Length` describes the payload size on the wire. ## moq-lite-07 +- Named 128 the midpoint priority for an unset Subscriber or Publisher Priority, and allowed a relay to scope priority to the broadcast on a relay-to-relay connection. - Added TRACK_INFO `Refresh` and SUBSCRIBE_UPDATE `Wanted Group`, carrying ratio-based refresh demand through relays. - Replaced ANNOUNCE_OK Active Count with an ordered, empty SYNCED event (0x4), marking the initial set without retaining routes that end before delivery. diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index bd15cc4997..e8ed0d76ac 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed + +`TrackInfo.Priority`, `Subscription.Priority`, and `FetchGroupOptions.Priority` are `int8`, and `GroupRequest.Priority()` returns one. +Higher is first and the zero value is the unset midpoint, so a `TrackInfo` that leaves `Priority` zero now gets the default rather than the least urgent priority; drop any `Priority: 127` that asked for the midpoint. +To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. + ### JSON consumers return `*json.Timed` (#5677) `SnapshotConsumer.Next`, `StreamConsumer.Next`, and `All` return `*Timed{Value, Timestamp}` instead of the raw message; `Timestamp` is a `*time.Duration`, nil when untimed. diff --git a/go/wrapper/json/json_test.go b/go/wrapper/json/json_test.go index 16f57e22fb..810040fa7c 100644 --- a/go/wrapper/json/json_test.go +++ b/go/wrapper/json/json_test.go @@ -55,7 +55,7 @@ func TestTracks(t *testing.T) { broadcast, consumer := newBroadcast(t) scale := uint64(1_000_000) - track, err := broadcast.PublishTrack("status", &moq.TrackInfo{Priority: 127, Timescale: &scale}) + track, err := broadcast.PublishTrack("status", &moq.TrackInfo{Timescale: &scale}) if err != nil { t.Fatal(err) } diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 02858ed518..367c02a44b 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -1594,7 +1594,7 @@ func ts(d time.Duration) *time.Duration { return &d } // timedInfo declares the microsecond timeline used by timed raw fixtures. func timedInfo() *moq.TrackInfo { scale := uint64(1_000_000) - return &moq.TrackInfo{Priority: 127, Timescale: &scale} + return &moq.TrackInfo{Timescale: &scale} } // untilWanted blocks until some subscriber wants the track, or none does. diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 172a43dc64..be09c03485 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -337,7 +337,7 @@ func (r *GroupRequest) Sequence() uint64 { } // Priority is the consumer's delivery priority for this fetch. -func (r *GroupRequest) Priority() uint8 { +func (r *GroupRequest) Priority() int8 { return r.inner.Priority() } diff --git a/go/wrapper/records.go b/go/wrapper/records.go index b6a5e827e7..8114315c06 100644 --- a/go/wrapper/records.go +++ b/go/wrapper/records.go @@ -63,7 +63,7 @@ func datagramFromFFI(d *ffi.MoqDatagram) *Datagram { // still allows, while an empty non-nil slice asks for nothing), and the order the // publisher sends pending groups in (nil for newest first). type Subscription struct { - Priority uint8 + Priority int8 MaxDelay time.Duration Ranges []Range Order *Order @@ -95,10 +95,10 @@ func subscriptionFFI(s *Subscription) (*ffi.MoqSubscription, error) { // non-latest group is cached (nil for no limit), how many times its first frame // a group's later bytes may grow before a joining subscriber asks for a new group // (nil never asks), and the timescale in ticks per second (nil means untimed on -// both publish and receive). A zero Priority is the least urgent; set 127 for the +// both publish and receive). Priority is higher first; the zero Priority is the // midpoint a nil TrackInfo uses. type TrackInfo struct { - Priority uint8 + Priority int8 MaxAge *time.Duration Refresh *uint32 Timescale *uint64 diff --git a/js/hang/src/catalog/priority.ts b/js/hang/src/catalog/priority.ts index b3fead65d8..50534d47b3 100644 --- a/js/hang/src/catalog/priority.ts +++ b/js/hang/src/catalog/priority.ts @@ -1,5 +1,8 @@ // Defined in one place so the relative ordering stays consistent. -/** Delivery priority per track kind; higher is sent first. */ +/** + * Delivery priority per track kind, in `-128..=127` with higher sent first. All sit above 0, the + * unset default, so hang media outranks a track that never set a priority. + */ export const PRIORITY = { catalog: 100, text: 90, diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 4d7076305f..fd478350ca 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed + +`Track.Info.priority`, `Track.Subscription.priority`, and a fetch's `priority` take `-128..=127`, higher first, and 0 is the unset midpoint; anything outside throws a `RangeError`. +The track's default was 127 and the subscription's 0, the least urgent; both are 0 now. +To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. +The wire messages (`Lite.Subscribe`, `Lite.TrackInfo`, `Ietf.Subscribe`, ...) hold the same signed priority, mapped to the wire byte when encoded. + ### `largest()` reports within a reader's `setGroups` bounds `Track.Subscriber.largest()` and `Track.Ordered.largest()` report the track's largest only while it is within the bounds set with `setGroups` or `withGroups`, where they used to ignore the bounds. diff --git a/js/net/src/ietf/object.ts b/js/net/src/ietf/object.ts index ae61a0d96d..e7b068b795 100644 --- a/js/net/src/ietf/object.ts +++ b/js/net/src/ietf/object.ts @@ -2,6 +2,7 @@ import { ProtocolViolation, StreamCode, Stream as StreamError } from "../error.t import { asIetf, type Cursor, type Reader, Writer } from "../stream.ts"; import { type Timescale, Timestamp } from "../time.ts"; import { U64 } from "../util/u64.ts"; +import { ABSENT_WIRE } from "./priority.ts"; import { type IetfVersion, Version } from "./version.ts"; // Implementation limit for object extension blocks, independent of the IETF draft. @@ -244,7 +245,7 @@ export class Group { const trackAlias = await r.u62(); const groupId = await r.u53(); const subGroupId = flags.hasSubgroup ? await r.u53() : 0; - const publisherPriority = hasPriority ? await r.u8() : 128; // Default priority when absent + const publisherPriority = hasPriority ? await r.u8() : ABSENT_WIRE; return new Group({ trackAlias, groupId, subGroupId, publisherPriority, flags }); } diff --git a/js/net/src/ietf/priority.test.ts b/js/net/src/ietf/priority.test.ts index b04da83354..04ff32b078 100644 --- a/js/net/src/ietf/priority.test.ts +++ b/js/net/src/ietf/priority.test.ts @@ -1,24 +1,33 @@ import { expect, test } from "bun:test"; +import * as Lite from "../lite/priority.ts"; import { infoDefaults } from "../track.ts"; -import { fromWire, toWire } from "./priority.ts"; +import { ABSENT_WIRE, fromWire, toWire } from "./priority.ts"; -test("IETF subscriber priority is lower first", () => { - expect(fromWire(0)).toBe(0xff); - expect(fromWire(0xff)).toBe(0); - expect(toWire(0xff)).toBe(0); - expect(toWire(0)).toBe(0xff); +test("IETF priority is lower first", () => { + expect(fromWire(0)).toBe(127); + expect(fromWire(0xff)).toBe(-128); + expect(toWire(127)).toBe(0); + expect(toWire(-128)).toBe(0xff); for (let priority = 0; priority < 0xff; priority++) { expect(fromWire(priority)).toBeGreaterThan(fromWire(priority + 1)); } }); -test("subscriber priority round trips", () => { +test("priority round trips", () => { for (let priority = 0; priority <= 0xff; priority++) { - expect(fromWire(toWire(priority))).toBe(priority); + expect(toWire(fromWire(priority))).toBe(priority); } }); -test("an unset track priority is the draft's usual publisher priority", () => { - expect(toWire(infoDefaults().priority)).toBe(128); +// Both wires carry one urgency: the IETF byte is the moq-lite byte flipped. +test("IETF mirrors moq-lite", () => { + for (let priority = -128; priority <= 127; priority++) { + expect(toWire(priority)).toBe(0xff - Lite.toWire(priority)); + } +}); + +test("an unset track priority goes out as the absent byte", () => { + expect(toWire(infoDefaults().priority)).toBe(127); + expect(fromWire(ABSENT_WIRE)).toBe(0); }); diff --git a/js/net/src/ietf/priority.ts b/js/net/src/ietf/priority.ts index 43c4732a41..991475e0ed 100644 --- a/js/net/src/ietf/priority.ts +++ b/js/net/src/ietf/priority.ts @@ -1,9 +1,22 @@ -/** Convert an IETF subscriber priority into the model's higher-first priority. */ +/** + * Priority conversion between the moq-transport wire and the model, mirroring the Rust + * `ietf::priority`. + * + * moq-transport ranks lower first, so the wire carries `127 - priority`: the unset priority 0 + * goes out as 127, and an absent priority decodes as 0. + * + * @module + */ + +/** The wire byte for the unset priority 0, which an absent priority stands for. */ +export const ABSENT_WIRE = 127; + +/** Convert an IETF priority byte into the model's higher-first priority. */ export function fromWire(priority: number): number { - return 0xff - priority; + return 127 - priority; } -/** Convert the model's higher-first priority into an IETF subscriber priority. */ +/** Convert the model's higher-first priority into an IETF priority byte. */ export function toWire(priority: number): number { - return 0xff - priority; + return 127 - priority; } diff --git a/js/net/src/ietf/properties.ts b/js/net/src/ietf/properties.ts index 3582983217..2f7d91fa61 100644 --- a/js/net/src/ietf/properties.ts +++ b/js/net/src/ietf/properties.ts @@ -30,7 +30,7 @@ export interface Properties { /// `undefined` declares no timeline, so the track and its objects are untimed. timescale?: Timescale; - /// Publisher priority for a group header without its priority flag. The wire default is 128. + /// Publisher priority for a group header without its priority flag. The wire default is 127, the unset priority 0. priority?: number; /// The publisher's preference for prioritizing groups within a subscription, diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index f33eed8c22..2211439fc3 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -2312,7 +2312,7 @@ test("drafts 14 to 16 apply an update and still end a subscription on UNSUBSCRIB expect(await peer.reader.u53()).toBe(RequestOk.id); expect((await RequestOk.decode(peer.reader, version)).requestId).toBe(2n); } - while (track.subscription.peek()?.priority !== 245) await track.subscription.changed(); + while (track.subscription.peek()?.priority !== 117) await track.subscription.changed(); await peer.writer.u53(Unsubscribe.id); await new Unsubscribe({ requestId: 0n }).encode(peer.writer, version); @@ -3582,14 +3582,14 @@ test("REQUEST_UPDATE applies priority and preserves it when omitted", async () = await client.writer.write(new Uint8Array([0x02, 0, 4, 2, 1, 0x20, 10])); expect(await client.reader.u53()).toBe(RequestOk.id); await RequestOk.decode(client.reader, fx.version); - expect(track.subscription.peek()?.priority).toBe(245); + expect(track.subscription.peek()?.priority).toBe(117); // Only the priority changes; retention and the group range survive. expect(track.subscription.peek()?.maxDelay).toBe(before?.maxDelay); expect(track.subscription.peek()?.groups).toEqual(before?.groups); await client.writer.write(new Uint8Array([0x02, 0, 2, 4, 0])); expect(await client.reader.u53()).toBe(RequestOk.id); await RequestOk.decode(client.reader, fx.version); - expect(track.subscription.peek()?.priority).toBe(245); + expect(track.subscription.peek()?.priority).toBe(117); } finally { client.close(); track.close(); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 2d48c91f4d..8fdc67208f 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -721,7 +721,7 @@ export class Publisher { const groups = new Set>(); const streams: StreamCount = { opened: 0 }; // One ranking for the whole subscription, shared by every group and the fill. - const ranked = new Priority(track); + const ranked = new Priority(track, info.priority); ranking = ranked; // Serve groups until the track ends or the requester cancels. diff --git a/js/net/src/ietf/subscribe.ts b/js/net/src/ietf/subscribe.ts index d00a83d75f..74e1be225b 100644 --- a/js/net/src/ietf/subscribe.ts +++ b/js/net/src/ietf/subscribe.ts @@ -7,6 +7,7 @@ import * as Filter from "./filter.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; import { type MessageLocation, MSG_PARAM_FORWARD, MSG_PARAM_SUBSCRIBER_PRIORITY, Parameters } from "./parameters.ts"; +import { ABSENT_WIRE } from "./priority.ts"; import * as Properties from "./properties.ts"; import { type IetfVersion, Version } from "./version.ts"; @@ -220,7 +221,7 @@ export class Subscribe { } // v15+: fields are in parameters const params = await Parameters.decode(r, version, "subscribe"); - const subscriberPriority = params.subscriberPriority ?? 128; + const subscriberPriority = params.subscriberPriority ?? ABSENT_WIRE; const groupOrder = decodeGroupOrder(params.groupOrder ?? GroupOrder.Descending); // FILL_PARAMETERS and INCLUDE_PROPERTIES are draft-20 additions, and the Range @@ -459,7 +460,7 @@ export class SubscribeUpdate { await w.u62(0n); // start_group await w.u62(0n); // start_object await w.u62(0n); // end_group - await w.u8(this.parameters.subscriberPriority ?? 128); + await w.u8(this.parameters.subscriberPriority ?? ABSENT_WIRE); await w.bool(this.parameters.forward ?? true); await this.parameters.encode(w, version, [MSG_PARAM_SUBSCRIBER_PRIORITY, MSG_PARAM_FORWARD]); } else { diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index d18e12dc1c..c0151308b6 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -1174,9 +1174,9 @@ test("a compact END_OF_TRACK at object 5 with properties is subgroup 5, refused } }); -test("older peer without priority property inherits wire priority 128", async () => { +test("older peer without priority property inherits the unset priority", async () => { const { subscriber, track } = await subscribeTrack(); - expect((await track.info()).priority).toBe(0xff - 128); + expect((await track.info()).priority).toBe(0); const group = new GroupMessage({ trackAlias: ALIAS, groupId: 3, @@ -1185,7 +1185,7 @@ test("older peer without priority property inherits wire priority 128", async () flags: { ...groupFlags(true), hasPriority: false }, }); await subscriber.handleGroup(group, new Reader(undefined, encodeObjects([0]), VERSION)); - expect(group.publisherPriority).toBe(128); + expect(group.publisherPriority).toBe(127); track.close(); }); @@ -1205,7 +1205,7 @@ test("an info-only lookup waits for SUBSCRIBE_OK instead of abandoning", async ( trackAlias: ALIAS, properties: { priority: 37 }, }).encode(peer.writer, VERSION); - expect((await info).priority).toBe(0xff - 37); + expect((await info).priority).toBe(127 - 37); }); test("SUBSCRIBE_OK's largest is known before any group arrives", async () => { @@ -1299,7 +1299,7 @@ test.each(["acceptance", "timeout"])("returning demand preserves pending setup u if (outcome === "acceptance") { await peer.writer.u53(SubscribeOk.id); await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(peer.writer, VERSION); - expect(await info).toMatchObject({ priority: 127 }); + expect(await info).toMatchObject({ priority: 0 }); expect(returned.closed.peek()).toBeUndefined(); } else { jest.advanceTimersByTime(4_000); @@ -1366,7 +1366,7 @@ test("setup abandonment closes before a queued viewer can attach", async () => { const request = await Subscribe.decode(next.reader, VERSION); await next.writer.u53(SubscribeOk.id); await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(next.writer, VERSION); - expect(await info).toMatchObject({ priority: 127 }); + expect(await info).toMatchObject({ priority: 0 }); }); test("abandonment cancels once and a late acceptance cannot capture a reused alias", async () => { @@ -1472,7 +1472,7 @@ test("early group waits for SUBSCRIBE_OK priority before track acceptance", asyn properties: { priority: 37 }, }).encode(peer.writer, VERSION); await arriving; - expect((await track.info()).priority).toBe(0xff - 37); + expect((await track.info()).priority).toBe(127 - 37); expect(group.publisherPriority).toBe(37); const ordered = track.ordered(); expect((await ordered.nextGroup())?.sequence).toBe(3); @@ -1485,7 +1485,7 @@ test("early group waits for SUBSCRIBE_OK priority before track acceptance", asyn }); await subscriber.handleGroup(explicit, new Reader(undefined, encodeObjects([0]), VERSION)); expect(explicit.publisherPriority).toBe(9); - expect((await track.info()).priority).toBe(0xff - 37); + expect((await track.info()).priority).toBe(127 - 37); expect((await ordered.nextGroup())?.sequence).toBe(4); ordered.close(); track.close(); @@ -2297,7 +2297,7 @@ for (const negotiated of [false, true]) { expect(await peer.reader.u53()).toBe(SubscribeUpdate.id); const update = await SubscribeUpdate.decode(peer.reader, VERSION); expect(update.parameters.maxAge).toBe(negotiated ? 900 : undefined); - expect(update.parameters.subscriberPriority).toBe(246); + expect(update.parameters.subscriberPriority).toBe(127 - 9); tolerant.close(); track.close(); peer.close(); @@ -2344,7 +2344,7 @@ test("a draft 16 subscription update waits for the last one's answer", async () await new RequestOk({ requestId: first.ownRequestId }).encode(peer.writer, version); expect(await next).toBe(SubscribeUpdate.id); const second = await SubscribeUpdate.decode(peer.reader, version); - expect(second.parameters.subscriberPriority).toBe(235); + expect(second.parameters.subscriberPriority).toBe(127 - 20); higher.close(); highest.close(); } finally { diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index bf621937bc..bcce43a540 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -54,7 +54,7 @@ import { ObjectIdGap, } from "./object.ts"; import { Parameters } from "./parameters.ts"; -import { fromWire, toWire } from "./priority.ts"; +import { ABSENT_WIRE, fromWire, toWire } from "./priority.ts"; import { type Publish, PublishDone, PublishDoneStatus, publishDoneClean } from "./publish.ts"; import { type AdvertParams, @@ -1203,7 +1203,7 @@ export class Subscriber { // No TIMESCALE (always so on drafts 14-16, which can't carry it) means no timeline, // and the track must not claim one when served onward. timescale: ok.properties.timescale, - priority: fromWire(ok.properties.priority ?? 128), + priority: fromWire(ok.properties.priority ?? ABSENT_WIRE), maxAge: maxCacheDuration === undefined ? undefined : Milli(Number(maxCacheDuration)), }); if (ok.largest && ok.largest.groupId > BigInt(Number.MAX_SAFE_INTEGER)) { diff --git a/js/net/src/lite/fetch.ts b/js/net/src/lite/fetch.ts index 823f85a626..d433ddf134 100644 --- a/js/net/src/lite/fetch.ts +++ b/js/net/src/lite/fetch.ts @@ -4,6 +4,7 @@ import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import { decodeEpoch, encodeEpoch } from "./epoch.ts"; import * as Message from "./message.ts"; +import { fromWire, toWire } from "./priority.ts"; import { hasFetchMaxDelay, hasFrameBounds, Version } from "./version.ts"; function guardFetch(version: Version) { @@ -68,7 +69,7 @@ export class Fetch { await w.string(Path.encode(this.broadcast)); await encodeEpoch(w, version, this.epoch); await w.string(this.track); - await w.u8(this.priority); + await w.u8(toWire(this.priority)); if (hasFetchMaxDelay(version)) await w.u53(this.maxDelay === undefined ? 0 : this.maxDelay + 1); else if (this.maxDelay !== undefined) throw new Error("fetch maxDelay not supported for this version"); await w.u53(this.group); @@ -86,7 +87,7 @@ export class Fetch { const broadcast = Path.decode(await r.string()); const epoch = await decodeEpoch(r, version); const track = await r.string(); - const priority = await r.u8(); + const priority = fromWire(await r.u8()); const encodedMaxDelay = hasFetchMaxDelay(version) ? await r.u53() : 0; const maxDelay = encodedMaxDelay > 0 ? ((encodedMaxDelay - 1) as Milli) : undefined; const group = await r.u53(); diff --git a/js/net/src/lite/priority.ts b/js/net/src/lite/priority.ts new file mode 100644 index 0000000000..2de185440d --- /dev/null +++ b/js/net/src/lite/priority.ts @@ -0,0 +1,19 @@ +/** + * Priority conversion between the moq-lite wire and the model, mirroring the Rust + * `lite::priority`. + * + * The wire carries `priority + 128`: one-to-one and order preserving, so a byte means what it + * always meant and the unset priority 0 is byte 128. + * + * @module + */ + +/** Convert a moq-lite priority byte into the model's priority. */ +export function fromWire(byte: number): number { + return byte - 128; +} + +/** Convert the model's priority into a moq-lite priority byte. */ +export function toWire(priority: number): number { + return priority + 128; +} diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index c9e64cc433..ae9b83d8c1 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -855,6 +855,9 @@ export class Publisher { try { // Older drafts carry no timestamps. let timescale: Timescale | undefined; + // Breaks ties between this session's subscriptions at one subscriber priority. Drafts + // before lite-05 serve before the track's info resolves, so they rank by the default. + let publisher = 0; if (hasTrackStream(this.version)) { // Lite-05+ accepts implicitly: no SUBSCRIBE_OK (the immutable @@ -868,6 +871,7 @@ export class Publisher { // stream. const info = await untilAborted(track.info(), left.signal); timescale = wireTimescale(this.version, info.timescale); + publisher = info.priority; } else { // Older drafts acknowledge with SUBSCRIBE_OK and stream frames verbatim. const ok = new SubscribeOk({ @@ -914,6 +918,7 @@ export class Publisher { sub: msg.id, broadcast: msg.broadcast, timescale, + publisher, served, bounds: { startGroup: msg.startGroup, @@ -1082,11 +1087,13 @@ export class Publisher { sub: bigint; broadcast: Path.Valid; timescale: Timescale | undefined; + /** The track's publisher priority, breaking ties between subscriptions. */ + publisher: number; served: Run[] | undefined; bounds: FrameBounds; }, ): Promise { - const { sub, broadcast, timescale, served, bounds } = serving; + const { sub, broadcast, timescale, publisher, served, bounds } = serving; // Lite-05+ resolves the range on the subscribe stream: SUBSCRIBE_START once the // first group is known, SUBSCRIBE_END when the track finishes. const emitRange = hasTrackStream(this.version); @@ -1129,7 +1136,7 @@ export class Publisher { }; // One ranking for the whole subscription, shared by every group it serves. - const priority = new Priority(track); + const priority = new Priority(track, publisher); // Every group this subscription started serving, until its stream finishes or resets. const groups = new Set>(); diff --git a/js/net/src/lite/subscribe.test.ts b/js/net/src/lite/subscribe.test.ts index 5f029bcc6c..b9010a0b1d 100644 --- a/js/net/src/lite/subscribe.test.ts +++ b/js/net/src/lite/subscribe.test.ts @@ -276,8 +276,8 @@ test.each(["ascending", "descending"] as const)("lite-07 round-trips a %s group expect(got.order).toBe(order); const update = await encodeMessage(version, new SubscribeUpdate({ priority: 3, order, maxDelay: 250 })); - // The length prefix, the priority, then the order. - expect(update[1]).toBe(3); + // The length prefix, the priority byte, then the order. + expect(update[1]).toBe(3 + 128); expect(update[2]).toBe(order === "ascending" ? 1 : 0); const decoded = await SubscribeUpdate.decode(new Reader(undefined, update, version), version); expect(decoded.order).toBe(order); diff --git a/js/net/src/lite/subscribe.ts b/js/net/src/lite/subscribe.ts index b2a6903997..c0e431f5dc 100644 --- a/js/net/src/lite/subscribe.ts +++ b/js/net/src/lite/subscribe.ts @@ -5,6 +5,7 @@ import type { Reader, Writer } from "../stream.ts"; import type { Location, Order } from "../track.ts"; import { decodeEpoch, encodeEpoch } from "./epoch.ts"; import * as Message from "./message.ts"; +import { fromWire, toWire } from "./priority.ts"; import { hasFrameBounds, hasGroupOrder, hasLargest, hasOrder, hasRefresh, resolvesStart, Version } from "./version.ts"; /** @@ -221,10 +222,10 @@ export class SubscribeUpdate { switch (version) { case Version.DRAFT_01: case Version.DRAFT_02: - await w.u8(this.priority); + await w.u8(toWire(this.priority)); break; default: - await w.u8(this.priority); + await w.u8(toWire(this.priority)); await encodeOrder(w, version, this.order); await w.u53(this.maxDelay); await encodeStartGroup(w, version, this.startGroup); @@ -240,9 +241,9 @@ export class SubscribeUpdate { switch (version) { case Version.DRAFT_01: case Version.DRAFT_02: - return new SubscribeUpdate({ priority: await r.u8() }); + return new SubscribeUpdate({ priority: fromWire(await r.u8()) }); default: { - const priority = await r.u8(); + const priority = fromWire(await r.u8()); const order = await decodeOrder(r, version); const maxDelay = await r.u53(); const startGroup = await decodeStartGroup(r, version); @@ -342,7 +343,7 @@ export class Subscribe { await w.string(Path.encode(this.broadcast)); await encodeEpoch(w, version, this.epoch); await w.string(this.track); - await w.u8(this.priority); + await w.u8(toWire(this.priority)); switch (version) { case Version.DRAFT_01: @@ -363,7 +364,7 @@ export class Subscribe { const broadcast = Path.decode(await r.string()); const epoch = await decodeEpoch(r, version); const track = await r.string(); - const priority = await r.u8(); + const priority = fromWire(await r.u8()); switch (version) { case Version.DRAFT_01: @@ -437,12 +438,12 @@ export class SubscribeOk { // noop break; case Version.DRAFT_01: - await w.u8(this.priority ?? 0); + await w.u8(toWire(this.priority)); break; // Draft-05+ never sends SUBSCRIBE_OK, but keep the field layout matching // Draft-03/04 so a stray future use stays well-formed. default: - await w.u8(this.priority); + await w.u8(toWire(this.priority)); await padGroupOrder(w, version); await w.u53(this.maxDelay); await w.u53(this.startGroup !== undefined ? this.startGroup + 1 : 0); @@ -462,10 +463,10 @@ export class SubscribeOk { // noop break; case Version.DRAFT_01: - priority = await r.u8(); + priority = fromWire(await r.u8()); break; default: - priority = await r.u8(); + priority = fromWire(await r.u8()); await skipGroupOrder(r, version); maxDelay = await r.u53(); startGroup = await r.u53(); diff --git a/js/net/src/lite/track.test.ts b/js/net/src/lite/track.test.ts index e0c776bf82..599b352959 100644 --- a/js/net/src/lite/track.test.ts +++ b/js/net/src/lite/track.test.ts @@ -52,7 +52,7 @@ test("TrackInfo for Rust's default Info matches cross-language wire bytes", asyn const model = infoDefaults(); const info = new TrackInfo({ ...model, timescale: model.timescale ?? undefined }); expect(await bytes((w) => info.encode(w, Version.DRAFT_05), Version.DRAFT_05)).toEqual( - new Uint8Array([0x0c, 0x7f, 0x00, 0xc0, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x43, 0xe8]), + new Uint8Array([0x0c, 0x80, 0x00, 0xc0, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x43, 0xe8]), ); }); @@ -93,7 +93,7 @@ test("TRACK_INFO is rejected before draft-05", async () => { test("TrackInfo round-trips valid boundary metadata", async () => { const info = new TrackInfo({ - priority: 255, + priority: 127, maxAge: Number.MAX_SAFE_INTEGER - 1, timescale: Number.MAX_SAFE_INTEGER, }); @@ -103,15 +103,15 @@ test("TrackInfo round-trips valid boundary metadata", async () => { Version.DRAFT_05, ); const got = await TrackInfo.decode(reader, Version.DRAFT_05); - expect(got.priority).toBe(255); + expect(got.priority).toBe(127); expect(got.maxAge).toBe(Number.MAX_SAFE_INTEGER - 1); expect(got.timescale).toBe(Timescale(Number.MAX_SAFE_INTEGER)); }); test("TrackInfo refuses invalid fields at construction", () => { for (const fields of [ - { priority: 256, timescale: 1000 }, - { priority: -1, timescale: 1000 }, + { priority: 128, timescale: 1000 }, + { priority: -129, timescale: 1000 }, { priority: 1.5, timescale: 1000 }, { timescale: 0 }, { timescale: -1 }, diff --git a/js/net/src/lite/track.ts b/js/net/src/lite/track.ts index e68210b456..62e56604d2 100644 --- a/js/net/src/lite/track.ts +++ b/js/net/src/lite/track.ts @@ -4,6 +4,7 @@ import type { Reader, Writer } from "../stream.ts"; import { Timescale } from "../time.ts"; import { decodeEpoch, encodeEpoch } from "./epoch.ts"; import * as Message from "./message.ts"; +import { fromWire, toWire } from "./priority.ts"; import { hasGroupOrder, hasRefresh, hasTrackStream, Version, wireTimescale } from "./version.ts"; function guardTrack(version: Version) { @@ -83,8 +84,8 @@ export class TrackInfo { timescale?: number; refresh?: number; }) { - if (!Number.isInteger(priority) || priority < 0 || priority > 255) { - throw new RangeError(`priority must be an integer in 0..=255: ${priority}`); + if (!Number.isInteger(priority) || priority < -128 || priority > 127) { + throw new RangeError(`priority must be an integer in -128..=127: ${priority}`); } if (maxAge !== undefined && (!Number.isSafeInteger(maxAge) || maxAge < 0)) { throw new RangeError(`maxAge must be a safe non-negative integer: ${maxAge}`); @@ -98,7 +99,7 @@ export class TrackInfo { } async #encode(w: Writer, version: Version) { - await w.u8(this.priority); + await w.u8(toWire(this.priority)); // The retired `Ordered` byte: lite-05 keeps it in its layout, written as 0. if (hasGroupOrder(version)) await w.bool(false); const legacy = version === Version.DRAFT_05 || version === Version.DRAFT_06; @@ -109,7 +110,7 @@ export class TrackInfo { } static async #decode(r: Reader, version: Version): Promise { - const priority = await r.u8(); + const priority = fromWire(await r.u8()); if (hasGroupOrder(version)) await r.bool(); const encoded = await r.u62(); const legacy = version === Version.DRAFT_05 || version === Version.DRAFT_06; @@ -134,8 +135,8 @@ export class TrackInfo { guardTrack(version); // Re-check after construction: fields are public, and a mutated value must not // reach the length-prefixed writer. - if (!Number.isInteger(this.priority) || this.priority < 0 || this.priority > 255) { - throw new RangeError(`priority must be an integer in 0..=255: ${this.priority}`); + if (!Number.isInteger(this.priority) || this.priority < -128 || this.priority > 127) { + throw new RangeError(`priority must be an integer in -128..=127: ${this.priority}`); } if (this.maxAge !== undefined && (!Number.isSafeInteger(this.maxAge) || this.maxAge < 0)) { throw new RangeError(`maxAge must be a safe non-negative integer: ${this.maxAge}`); diff --git a/js/net/src/priority.test.ts b/js/net/src/priority.test.ts index 1a38e24175..e9d2335adb 100644 --- a/js/net/src/priority.test.ts +++ b/js/net/src/priority.test.ts @@ -6,16 +6,28 @@ import { type SendStream, Writer } from "./stream.ts"; import { Timescale } from "./time.ts"; import type { Subscription } from "./track.ts"; -// The last position that still fits below the track priority. -const MAX_POSITION = 2 ** 44 - 1; +// The last position that still fits below the track priorities. +const MAX_POSITION = 2 ** 36 - 1; // The transport sends higher values first, so a track the subscriber cares about more has to // outrank one it cares about less, wherever their groups sit in their own queues. test("track priority dominates the position", () => { expect(sendOrder({ priority: 2, position: 1_000_000 })).toBeGreaterThan(sendOrder({ priority: 1, position: 0 })); - expect(sendOrder({ priority: 255, position: MAX_POSITION })).toBeGreaterThan( - sendOrder({ priority: 254, position: 0 }), + expect(sendOrder({ priority: 127, position: MAX_POSITION })).toBeGreaterThan( + sendOrder({ priority: 126, position: 0 }), ); + expect(sendOrder({ priority: 0, position: 0 })).toBeGreaterThan(sendOrder({ priority: -1, position: 0 })); +}); + +// The publisher's priority breaks a tie at one subscriber priority, as in the Rust +// `Priority::cmp`, but never outranks a higher subscriber priority. +test("the publisher priority breaks subscriber ties", () => { + expect(sendOrder({ priority: 0, publisher: 80 })).toBeGreaterThan(sendOrder({ priority: 0, publisher: 60 })); + expect(sendOrder({ priority: 0, publisher: 1, position: MAX_POSITION })).toBeGreaterThan( + sendOrder({ priority: 0, publisher: 0, position: 0 }), + ); + expect(sendOrder({ priority: 1, publisher: -128 })).toBeGreaterThan(sendOrder({ priority: 0, publisher: 127 })); + expect(sendOrder({ priority: 0 })).toBe(sendOrder({ priority: 0, publisher: 0 })); }); // Position 0 is the group its subscription wants sent next, so it goes first. @@ -33,15 +45,15 @@ test("equal priorities tie at the same position", () => { // Every send order stays a safe integer, so two distinct ranks never collapse into one. test("send orders stay safe integers", () => { - expect(sendOrder({ priority: 255, position: 0 })).toBe(-1); - expect(sendOrder({ priority: 0, position: MAX_POSITION })).toBe(-(2 ** 52)); - expect(Number.isSafeInteger(sendOrder({ priority: 0, position: MAX_POSITION }))).toBe(true); + expect(sendOrder({ priority: 127, publisher: 127, position: 0 })).toBe(-1); + expect(sendOrder({ priority: -128, publisher: -128, position: MAX_POSITION })).toBe(-(2 ** 52)); + expect(Number.isSafeInteger(sendOrder({ priority: -128, publisher: -128, position: MAX_POSITION }))).toBe(true); }); // Protocol streams use the transport's default order of 0. Keeping every group below it // prevents a continuous supply of media from starving subscriptions, announcements, or probes. test("protocol streams outrank group data", () => { - expect(sendOrder({ priority: 255, position: 0 })).toBeLessThan(0); + expect(sendOrder({ priority: 127, publisher: 127, position: 0 })).toBeLessThan(0); }); // A position past the space left for it must never carry into the track's bits and outrank a @@ -50,8 +62,8 @@ test("out of range ranks stay inside their own bits", () => { expect(sendOrder({ priority: 1, position: Number.MAX_SAFE_INTEGER })).toBeLessThan( sendOrder({ priority: 2, position: 0 }), ); - expect(sendOrder({ priority: 300, position: 0 })).toBe(sendOrder({ priority: 255, position: 0 })); - expect(sendOrder({ priority: -1, position: -1 })).toBe(sendOrder({ priority: 0, position: 0 })); + expect(sendOrder({ priority: 300, position: 0 })).toBe(sendOrder({ priority: 127, position: 0 })); + expect(sendOrder({ priority: -300, position: -1 })).toBe(sendOrder({ priority: -128, position: 0 })); }); // The ranking itself, driven directly: a subscription outlives its own serving loop, since a diff --git a/js/net/src/priority.ts b/js/net/src/priority.ts index a0f82d60ae..42d1e4c31b 100644 --- a/js/net/src/priority.ts +++ b/js/net/src/priority.ts @@ -9,15 +9,12 @@ import type { Dispose } from "@moq/signals"; import type { Writer } from "./stream.ts"; import type * as track from "./track.ts"; -// The transport compares send orders as a single number, so the two ranks are packed into -// disjoint ranges: the track priority takes the high bits so it always dominates, and the -// position below it. Group data stays negative so protocol streams at the transport's default -// order of 0 always make progress. A u8 priority and 44-bit position fill the negative half of -// the safe integer range exactly. -const GROUP_SPAN = 2 ** 44; - -/** The highest track priority the wire carries (a u8). */ -const MAX_PRIORITY = 0xff; +// The transport compares send orders as a single number, so the ranks are packed into +// disjoint ranges: the subscriber's priority takes the high bits so it always dominates, the +// publisher's below it, and the position below that. Group data stays negative so protocol +// streams at the transport's default order of 0 always make progress. Two signed byte +// priorities and a 36-bit position fit the negative half of the safe integer range. +const GROUP_SPAN = 2 ** 36; /** * What to rank a group stream by. @@ -25,9 +22,15 @@ const MAX_PRIORITY = 0xff; * @internal */ export interface Rank { - /** The subscriber's track priority, which outweighs everything below it. */ + /** The subscriber's track priority (`-128..=127`), which outweighs everything below it. */ priority: number; + /** + * The publisher's track priority (`-128..=127`), breaking ties between subscriptions at one + * subscriber priority. Defaults to 0, the unset priority. + */ + publisher?: number; + /** * Where the group sits in the queue its own subscription wants sent, 0 being the one to * send next. Defaults to 0, which is right for a stream that is the only one of its kind. @@ -47,14 +50,15 @@ export interface Rank { * * @internal */ -export function sendOrder({ priority, position = 0 }: Rank): number { - return -((MAX_PRIORITY - clamp(priority, MAX_PRIORITY)) * GROUP_SPAN + clamp(position, GROUP_SPAN - 1) + 1); +export function sendOrder({ priority, publisher = 0, position = 0 }: Rank): number { + const rank = (127 - clamp(priority, -128, 127)) * 256 + (127 - clamp(publisher, -128, 127)); + return -(rank * GROUP_SPAN + clamp(position, 0, GROUP_SPAN - 1) + 1); } -// Both ranks are bounded: the priority by its wire type, the position by the space left below -// it. Anything outside is a caller bug rather than a value worth preserving. -function clamp(value: number, max: number): number { - return Math.min(Math.max(Math.trunc(value), 0), max); +// Every rank is bounded: the priorities by their wire type, the position by the space left +// below them. Anything outside is a caller bug rather than a value worth preserving. +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(Math.trunc(value), min), max); } // private: plumbing @@ -74,13 +78,18 @@ function clamp(value: number, max: number): number { */ export class Priority { #track: track.Subscriber; + #publisher: number; #streams = new Map(); #dispose: Dispose; #closed = false; - /** Follow `track`'s subscription until {@link close}. */ - constructor(track: track.Subscriber) { + /** + * Follow `track`'s subscription until {@link close}, breaking ties at one subscriber + * priority by the track's `publisher` priority. + */ + constructor(track: track.Subscriber, publisher = 0) { this.#track = track; + this.#publisher = publisher; this.#dispose = track.subscription.subscribe(() => this.#rerank()); } @@ -89,6 +98,7 @@ export class Priority { const subscription = this.#track.subscription.peek(); return sendOrder({ priority: subscription?.priority ?? 0, + publisher: this.#publisher, position: this.#position(sequence, object, subscription?.order), }); } @@ -147,7 +157,11 @@ export class Priority { const priority = subscription?.priority ?? 0; for (const [stream, { sequence, object }] of this.#streams) { stream.setPriority( - sendOrder({ priority, position: this.#position(sequence, object, subscription?.order) }), + sendOrder({ + priority, + publisher: this.#publisher, + position: this.#position(sequence, object, subscription?.order), + }), ); } } diff --git a/js/net/src/subscribe_priority.test.ts b/js/net/src/subscribe_priority.test.ts index 682259f139..3b160162b0 100644 --- a/js/net/src/subscribe_priority.test.ts +++ b/js/net/src/subscribe_priority.test.ts @@ -16,8 +16,8 @@ import { wireOf } from "./wire.ts"; const url = new URL("https://localhost:4443/test"); const path = Path.from("test"); -const LOW = 1; -const HIGH = 200; +const LOW = -100; +const HIGH = 100; // Dial a session that consumes what `origin` serves. Draft-14's ALPN is the generic one, so // the server is told the version instead. diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index e5929cce13..5f64c8c919 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -36,7 +36,7 @@ function manualTime(initial: number) { test("priority reads the committed info and is the midpoint before accept", () => { const producer = new TrackProducer("video"); - expect(producer.priority).toBe(127); + expect(producer.priority).toBe(0); producer.accept({ priority: 60 }); expect(producer.priority).toBe(60); }); @@ -457,14 +457,15 @@ test("a maxDelay or maxAge that is not a duration is refused, not rounded into o expect(() => producer.subscribe({ maxDelay: Milli(Number.MAX_SAFE_INTEGER + 1) })).toThrow(RangeError); }); -test("priority 255 is accepted and 256 is refused", () => { +test("priority -128..=127 is accepted and anything outside is refused", () => { const producer = new TrackProducer("video"); - producer.accept({ priority: 255 }); - expect(producer.priority).toBe(255); - expect(() => producer.accept({ priority: 256 })).toThrow(RangeError); - expect(() => producer.accept({ priority: -1 })).toThrow(RangeError); + producer.accept({ priority: 127 }); + expect(producer.priority).toBe(127); + expect(new TrackProducer("audio").subscribe({ priority: -128 }).subscription.peek()?.priority).toBe(-128); + expect(() => producer.accept({ priority: 128 })).toThrow(RangeError); + expect(() => producer.accept({ priority: -129 })).toThrow(RangeError); expect(() => producer.accept({ priority: 1.5 })).toThrow(RangeError); - expect(() => producer.subscribe({ priority: 256 })).toThrow(RangeError); + expect(() => producer.subscribe({ priority: 128 })).toThrow(RangeError); }); test("infoDefaults re-validates timescale and the safe maxAge bound", () => { diff --git a/js/net/src/track.ts b/js/net/src/track.ts index d6f11592e3..20c81b6fbd 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -38,9 +38,9 @@ const CACHE_WINDOW = Nano.fromMilli(Milli(30_000)); // The cache scans at most this many times per idle cache window. const PRUNE_SLICES = 8; -// The higher-first midpoint. IETF flips priority (lower first), so this goes out as 128, the -// draft's usual publisher priority, while moq-lite carries 127 as written: one urgency on both. -const DEFAULT_PRIORITY = 127; +// The unset priority, the midpoint of the signed range: moq-lite carries it as 128 and +// moq-transport as 127, one urgency on both. +const DEFAULT_PRIORITY = 0; /** Maximum buffered datagrams per subscriber; mirrors Rust's bounded send buffer. */ const MAX_DATAGRAMS = 64; @@ -96,7 +96,7 @@ export interface Info { * or non-finite value and a result past `Number.MAX_SAFE_INTEGER`. */ maxAge?: Milli; - /** Tie-break priority between subscriptions of equal subscriber priority (`0..=255`, higher first). Defaults to `127`. */ + /** Tie-break priority between subscriptions of equal subscriber priority (`-128..=127`, higher first). Defaults to `0`. */ priority: number; /** * How many times its first frame a group's later bytes may grow before a joining @@ -125,9 +125,9 @@ function wireMillis(field: string, value: Milli): Milli { return Milli(millis); } -function priorityByte(value: number): number { - if (!Number.isInteger(value) || value < 0 || value > 255) { - throw new RangeError(`priority must be an integer in 0..=255: ${value}`); +function priorityValue(value: number): number { + if (!Number.isInteger(value) || value < -128 || value > 127) { + throw new RangeError(`priority must be an integer in -128..=127: ${value}`); } return value; } @@ -137,7 +137,7 @@ export function infoDefaults(info: Partial = {}): Info { return { timescale: info.timescale == null ? undefined : Timescale(info.timescale), maxAge: info.maxAge === undefined ? undefined : wireMillis("maxAge", info.maxAge), - priority: priorityByte(info.priority ?? DEFAULT_PRIORITY), + priority: priorityValue(info.priority ?? DEFAULT_PRIORITY), refresh: info.refresh == null ? undefined : refreshRatio(info.refresh), }; } @@ -166,7 +166,7 @@ export interface Groups { * The obsolete subscriber `maxAge` field is refused at runtime; use `maxDelay`. */ export interface Subscription { - /** Delivery priority relative to this session's other subscriptions (`0..=255`). Defaults to `0`. */ + /** Delivery priority relative to this session's other subscriptions (`-128..=127`, higher first). Defaults to `0`. */ priority?: number; /** * How long (milliseconds) a non-latest group stays worth delivering, by the same two @@ -215,7 +215,7 @@ function subscriptionDefaults(subscription: Subscription = {}): Subscription { if ("maxAge" in subscription) throw new TypeError("Subscriber maxAge is unsupported; use maxDelay"); const bounds = groupBounds(subscription.groups ?? {}); return { - priority: priorityByte(subscription.priority ?? 0), + priority: priorityValue(subscription.priority ?? DEFAULT_PRIORITY), maxDelay: wireMillis("maxDelay", subscription.maxDelay ?? Milli.zero), order: orderOf(subscription.order), groups: { @@ -621,9 +621,9 @@ export class Demand { return this.#state.closed; } - /** Publisher priority from the committed {@link Info}, or 0 before {@link Request.accept}. */ + /** Publisher priority from the committed {@link Info}, or the default before {@link Request.accept}. */ get priority(): number { - return this.#state.info.peek()?.priority ?? 0; + return this.#state.info.peek()?.priority ?? DEFAULT_PRIORITY; } } diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 51b55968ef..23ba12fd7f 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed + +`TrackInfo.priority`, `Subscription.priority`, and `FetchGroupOptions.priority` are `Byte`, higher first, and 0 is the unset midpoint; write `priority = 3` instead of `3u`. +`TrackInfo.priority` defaulted to 127 and now defaults to 0. +To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. + ### JSON consumers emit timestamps (#5677) `values()` emits `json.Value` (`value`, plus `timestamp` as a `Duration?`) instead of a string, and `valuesAs()` emits `Timed(value, timestamp)`. diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index 691a879997..1120851959 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -247,7 +247,7 @@ class SmokeTest { val fetched = broadcast.consume().fetchGroup( "events", 0uL, - FetchGroupOptions(priority = 3u), + FetchGroupOptions(priority = 3), ) assertEquals(0uL, fetched.sequence()) assertEquals("cached", fetched.readFrame()?.payload?.decodeToString()) @@ -354,7 +354,7 @@ class SmokeTest { // Fetch while the track is still published: finishing the media producer // unpublishes it, and the fetch would then miss with NotFound. - val fetched = dev.moq.media.GroupConsumer.fetch(consumer, dev.moq.media.GroupConfig(rendition = dev.moq.media.AudioRendition(name, audio), sequence = 0uL, options = FetchGroupOptions(priority = 3u))) + val fetched = dev.moq.media.GroupConsumer.fetch(consumer, dev.moq.media.GroupConfig(rendition = dev.moq.media.AudioRendition(name, audio), sequence = 0uL, options = FetchGroupOptions(priority = 3))) // Close the group so the fetched stream terminates instead of waiting for more. media.finish() diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 8dac627d18..b72c637593 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed + +`TrackInfo.priority`, `Subscription.priority`, and `FetchGroupOptions.priority` take `-128..=127`, higher first, and 0 is the unset midpoint. +`TrackInfo.priority` defaulted to 127 and now defaults to 0. +To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. + ### JSON consumers yield `json.Timed` (#5677) `json.SnapshotConsumer` and `json.StreamConsumer` yield `Timed(value, timestamp)` instead of the bare value; `timestamp` is a `timedelta`, or `None` when untimed. diff --git a/py/moq-rs/moq/_records.py b/py/moq-rs/moq/_records.py index a065d3f725..9fbbbf3d98 100644 --- a/py/moq-rs/moq/_records.py +++ b/py/moq-rs/moq/_records.py @@ -92,6 +92,7 @@ def _from_ffi(datagram: MoqDatagram) -> Datagram: class Subscription: """Subscriber-side delivery preferences, mirroring moq-net's ``track::Subscription``. + ``priority`` is in ``-128..=127``, higher first, and defaults to 0, the midpoint. ``max_delay`` is how far a non-latest group may fall behind before it is skipped; zero skips at once, and it caps every range. ``ranges`` are the spans of the track to deliver, ``None`` for everything ``max_delay`` still allows. ``order`` is which pending @@ -120,14 +121,15 @@ def _subscription(subscription: Subscription | None) -> MoqSubscription | None: class TrackInfo: """Publisher-side track properties, mirroring moq-net's ``track::Info``. - ``priority`` defaults to 127, the middle of the range. ``max_age`` is how long the + ``priority`` is in ``-128..=127``, higher first, and defaults to 0, the midpoint. It breaks + ties between subscriptions of equal subscriber priority. ``max_age`` is how long the publisher caches a non-latest group, ``None`` for no limit. ``refresh`` is how many times its first frame a group's later bytes may grow before a joining subscriber asks for a new group, ``None`` to never ask. ``timescale`` is ticks per second: ``None`` means the track is untimed, on both publish and receive. """ - priority: int = 127 + priority: int = 0 max_age: timedelta | None = None refresh: int | None = None timescale: int | None = None diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 38621566fa..e62de02c73 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -440,7 +440,7 @@ async def test_publish_track_info_and_subscription(): def test_track_info_keeps_native_default_priority(): - """A partially configured TrackInfo keeps moq-ffi's default priority of 127.""" + """A partially configured TrackInfo keeps moq-ffi's default priority of 0.""" native = moq_ffi.MoqTrackInfo().priority assert moq.TrackInfo()._ffi().priority == native assert moq.TrackInfo(max_age=timedelta(seconds=2))._ffi().priority == native diff --git a/rs/hang/MIGRATE.md b/rs/hang/MIGRATE.md index 6cce9f986b..23d8e994c3 100644 --- a/rs/hang/MIGRATE.md +++ b/rs/hang/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed + +`catalog::Priorities` fields and `container::track_info`'s priority are `i8`, higher first, with 0 unset. +`PRIORITY` keeps its values (100, 90, 80, 60), which now sit above the unset default instead of below it, so their moq-lite bytes move from 100..60 to 228..188. + ### `M2ts::random_access` covers the whole track (#5967) `random_access` no longer describes the latest two-second window: it is true only while every group the track has published started at a random access point. diff --git a/rs/hang/src/catalog/priority.rs b/rs/hang/src/catalog/priority.rs index 9f4786452a..de52edadd0 100644 --- a/rs/hang/src/catalog/priority.rs +++ b/rs/hang/src/catalog/priority.rs @@ -41,6 +41,10 @@ mod tests { #[test] fn media_outranks_the_unset_default() { let unset = moq_net::track::Info::default().priority; - assert!([PRIORITY.catalog, PRIORITY.text, PRIORITY.audio, PRIORITY.video].iter().all(|&p| p > unset)); + assert!( + [PRIORITY.catalog, PRIORITY.text, PRIORITY.audio, PRIORITY.video] + .iter() + .all(|&p| p > unset) + ); } } diff --git a/rs/moq-archive/MIGRATE.md b/rs/moq-archive/MIGRATE.md index 93e4567040..8003d2b923 100644 --- a/rs/moq-archive/MIGRATE.md +++ b/rs/moq-archive/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Info::priority` is signed + +`Info::priority`, `Info::new`, and `Error::Priority` use `i8`, higher first, with 0 unset. +`.info` still stores the moq-lite byte, `priority + 128`, so existing recordings keep their meaning: a stored 127 reads as -1. + ### Untimed tracks record untimed (#5720) `Info::new` and `Info::timescale` take and return `Option`, and `Error::TimescaleMismatch` carries `Option` fields. diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index dac364224f..296f4bc136 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed + +`moq_track_info.priority` and `moq_subscription.priority` are `int8_t`, and `moq_group_request_priority` writes an `int8_t`, which changes the ABI. +Higher is first and 0 is the unset midpoint, so a zeroed `moq_track_info` now uses the default priority rather than the least urgent one. +To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. + ### JSON values carry their timestamp (#5677) `moq_json_value` gains `timestamp_us` and `timestamp_present`, which changes its ABI. diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 7c30fe8641..5f12e7c6f0 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,12 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### Priorities are signed + +`MoqTrackInfo.priority`, `MoqSubscription.priority`, `MoqFetchGroupOptions.priority`, and `MoqGroupRequest.priority()` are `i8`, higher first, and 0 is the unset midpoint. +`MoqTrackInfo.priority` defaulted to 127 and now defaults to 0. +To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. + ### JSON consumers return `MoqJsonValue` (#5677) `MoqJsonSnapshotConsumer::next` and `MoqJsonStreamConsumer::next` return `MoqJsonValue { value, timestamp_us }` instead of a string; `timestamp_us` is null when untimed. diff --git a/rs/moq-mux/MIGRATE.md b/rs/moq-mux/MIGRATE.md index 91b85a9b27..641829f9e7 100644 --- a/rs/moq-mux/MIGRATE.md +++ b/rs/moq-mux/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed + +`track_info` on the catalog producer and tracks takes an `i8` priority, higher first, with 0 unset; pass `hang::catalog::PRIORITY` as before. + ### JSON and binary consumers return `Timed` (#5677) The `json` and `binary` consumers' `next` / `poll_next` return `moq_net::Timed`; read `.value` and the optional `.at`. diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index f86f29361e..16f48d502f 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed, with 0 unset + +`track::Info::priority`, `track::Subscription::priority`, `group::Fetch::priority`, and `group::Request::priority()` are `i8`, higher first, and 0 is the unset midpoint. +The track's default was 127 and the subscription's 0, the least urgent; both are 0 now. +To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. +The publisher's `Info::priority` now breaks ties between subscriptions at one subscriber priority. + +On the wire, moq-lite carries `priority + 128` and moq-transport `127 - priority`, so an unset priority goes out as 128 on moq-lite (was 127) and 127 on moq-transport (was 128), and an absent moq-transport priority reads as 0. + ### Each group has one writer (#6020) `group::Producer` no longer implements `Clone`: keep one writer and move it by value between owners, or put it behind a lock when several callers write it. @@ -153,14 +162,16 @@ Hold one of those, such as `track.subscribe(None)` or `track.query()` to only le ### Custom transports declare send groups (#5711) A `transport::poll::SendStream` declares `type Group` (`Ungrouped` when the transport has none) and implements `set_group`, and `set_priority` takes an `i64`. -A `transport::poll::Session` implements `send_group`, returning `None` without groups. +A `transport::poll::Session` implements `send_domain` and `send_group`, returning `None` without groups. +A send group opens in a send domain, which shares bytes equally with the session's other domains, or in the session's default domain for `None`. ```rust type Group = Ungrouped; fn set_priority(&mut self, priority: i64) { ... } fn set_group(&mut self, group: &Ungrouped) { match *group {} } // and on the Session -fn send_group(&mut self, _priority: i64) -> Option { None } +fn send_domain(&mut self) -> Option { None } +fn send_group(&mut self, _domain: Option<&Ungrouped>, _priority: i64) -> Option { None } ``` ### Superseded groups age by wall clock too (#5548) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index cd92716e09..cafeef7d79 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -471,7 +471,11 @@ fn accepted_info(properties: &ietf::Properties) -> track::Info { let mut info = track::Info::default() .with_timescale(properties.timescale) .with_max_age(properties.max_cache_duration) - .with_priority(properties.priority.map_or(super::priority::ABSENT, super::priority::from_wire)) + .with_priority( + properties + .priority + .map_or(super::priority::ABSENT, super::priority::from_wire), + ) // A publisher answering NEW_GROUP_REQUEST refreshes on any join with a delta to skip; // this session carries the request back to it. .with_refresh(properties.dynamic_groups.then_some(0)); diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index cac7d977cf..c35e7efe20 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -102,9 +102,9 @@ pub mod path; #[cfg(feature = "async")] mod priority; #[cfg(feature = "async")] -mod scheduler; -#[cfg(feature = "async")] mod recv; +#[cfg(feature = "async")] +mod scheduler; pub mod setup; #[cfg(feature = "async")] mod tail; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 78463303db..4936fb6dda 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -19,8 +19,8 @@ use crate::{ coding::{Decode, DecodeError, Decoder, Encode, Stream, Writer}, lite, priority::{Priority, PriorityHandle, PriorityQueue}, - track::Order, scheduler::{Domain, Scheduler}, + track::Order, transport::poll::{DomainOf, GroupOf, SendGroup as _, SendOptions}, }; @@ -4135,13 +4135,11 @@ impl TrackRun { // subscription on the session, and only groups of the same one may be // ranked against each other by sequence, in that subscription's order. None => Rank::Queue( - self.ctx - .priority - .insert( - Priority::new(steer.priority, self.ctx.id, sequence) - .with_publisher(self.ctx.publisher) - .with_order(steer.order), - ), + self.ctx.priority.insert( + Priority::new(steer.priority, self.ctx.id, sequence) + .with_publisher(self.ctx.publisher) + .with_order(steer.order), + ), ), }; self.children.push(GroupServe::new( @@ -8104,10 +8102,7 @@ mod tests { } /// Two announced broadcasts, `a` and `b`, with their track requests. - fn two_broadcasts() -> ( - origin::Producer, - [(broadcast::Producer, broadcast::Dynamic); 2], - ) { + fn two_broadcasts() -> (origin::Producer, [(broadcast::Producer, broadcast::Dynamic); 2]) { let origin = crate::origin::Config::new(Hop::new(1).unwrap()).produce(); let broadcasts = ["a", "b"].map(|name| { let broadcast = origin.create_broadcast(name).unwrap(); @@ -8154,7 +8149,9 @@ mod tests { a_video.0.push(&update.encode_bytes(Version::Lite07).unwrap()); assert!(!drive(&mut a_video.1).await, "the update ended the subscription"); assert!( - log.groups().iter().all(|&(group, priority)| group != 0 || priority == 60), + log.groups() + .iter() + .all(|&(group, priority)| group != 0 || priority == 60), "the update moved the group: {:?}", log.groups() ); @@ -8172,8 +8169,24 @@ mod tests { let publisher = grouped_publisher(origin.consume(), crate::session::Schedule::Strict); let log = publisher.shared.session.log.clone(); - let _a = subscribe_at(&publisher, &mut a, "a", "video", i8::MAX, track::Info::timed().with_priority(60)).await; - let _b = subscribe_at(&publisher, &mut b, "b", "audio", i8::MIN, track::Info::timed().with_priority(80)).await; + let _a = subscribe_at( + &publisher, + &mut a, + "a", + "video", + i8::MAX, + track::Info::timed().with_priority(60), + ) + .await; + let _b = subscribe_at( + &publisher, + &mut b, + "b", + "audio", + i8::MIN, + track::Info::timed().with_priority(80), + ) + .await; assert_eq!( log.groups(), [ @@ -8199,7 +8212,11 @@ mod tests { let RequestState::Serve(SubscribeServe::Run(run)) = &serve.state else { panic!("the subscription is not running"); }; - assert_eq!(run.steer_tx.read().priority, i8::MAX, "the subscriber priority was dropped"); + assert_eq!( + run.steer_tx.read().priority, + i8::MAX, + "the subscriber priority was dropped" + ); } /// Scheduled per broadcast, a FETCH response joins its broadcast's domain at the @@ -8211,8 +8228,15 @@ mod tests { let publisher = grouped_publisher(origin.consume(), crate::session::Schedule::Broadcast); let log = publisher.shared.session.log.clone(); - let (_session, _serve, track) = - subscribe_at(&publisher, &mut a, "a", "video", 0, track::Info::timed().with_priority(60)).await; + let (_session, _serve, track) = subscribe_at( + &publisher, + &mut a, + "a", + "video", + 0, + track::Info::timed().with_priority(60), + ) + .await; let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); group.write_frame(crate::Timestamp::ZERO, b"x".as_slice()).unwrap(); group.finish().unwrap(); diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 698d2b69ee..b646cee0e2 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -216,7 +216,6 @@ where schedule, } = config; - let recv_bw = bandwidth::Producer::new(); let recv_bw_consumer = match version { diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 35e9b32fc6..652264475d 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -2223,6 +2223,7 @@ impl Consumer { /// The parent track's publisher priority, [`track::Info::priority`]. #[cfg(feature = "async")] + // private: passthrough pub(crate) fn track_priority(&self) -> i8 { self.track.priority } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 76ee2c7f5e..f70b00a37d 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -136,7 +136,7 @@ pub struct Info { /// The publisher's priority for this track, higher first, defaulting to 0. /// /// It breaks ties between subscriptions of equal subscriber priority. On a session - /// scheduled per broadcast ([`session::Schedule::Broadcast`](crate::session::Schedule::Broadcast)) + /// scheduled per broadcast (`session::Schedule::Broadcast`) /// it replaces the subscriber's priority, ordering the broadcast's tracks among /// themselves. Reported in TRACK_INFO (Lite05+). pub priority: i8, diff --git a/rs/moq-net/src/scheduler.rs b/rs/moq-net/src/scheduler.rs index 44f4ad9886..d196a6107c 100644 --- a/rs/moq-net/src/scheduler.rs +++ b/rs/moq-net/src/scheduler.rs @@ -89,7 +89,7 @@ impl Scheduler { if let Some(held) = domains.get(broadcast.as_str()).and_then(Weak::upgrade) { return Some(held); } - // A session scheduled per broadcast refuses to start on a transport without domains. + // `None` only on a transport without domains, where the publisher schedules strictly. let held = Arc::new(Held { domain: open()?, broadcast: broadcast.as_str().to_owned(), diff --git a/rs/moq-net/src/transport.rs b/rs/moq-net/src/transport.rs index 1318fab09c..afdc9bdb48 100644 --- a/rs/moq-net/src/transport.rs +++ b/rs/moq-net/src/transport.rs @@ -415,7 +415,8 @@ pub mod poll { impl Boxable for S where S: Session< - SendStream: MaybeSend + SendStream>, + SendStream: MaybeSend + + SendStream>, RecvStream: MaybeSend, Error: MaybeSend, > + MaybeSend diff --git a/rs/moq-quic/src/connection/mod.rs b/rs/moq-quic/src/connection/mod.rs index 72ed0480d9..d088f6ec5b 100644 --- a/rs/moq-quic/src/connection/mod.rs +++ b/rs/moq-quic/src/connection/mod.rs @@ -93,8 +93,9 @@ pub use stats::{ConnectionStats, CreditStats, FrameStats, PathStats, UdpStats}; mod streams; pub(crate) use streams::StreamsState; pub use streams::{ - Chunks, ClosedStream, Credit, FinishError, ReadError, ReadableError, RecvStream, SendDomain, SendGroup, - SendGroups, SendOptions, SendStream, ShouldTransmit, StreamEvent, Streams, WriteError, Written, + Chunks, ClosedStream, Credit, FinishError, ReadError, ReadableError, RecvStream, SendDomain, + SendGroup, SendGroups, SendOptions, SendStream, ShouldTransmit, StreamEvent, Streams, + WriteError, Written, }; mod timer; diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 0329dfdd8e..075ee9699b 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -2395,7 +2395,10 @@ mod tests { let (_producer, consumer) = moq_auth::lease::Producer::new(grant); let lease = auth::Lease::new("/.cluster/peer", consumer); assert_eq!(cluster.schedule(&lease), expected, "fair {fair}, peer {peer}"); - assert_eq!(cluster.peer_schedule(), if fair { Schedule::Broadcast } else { Schedule::Strict }); + assert_eq!( + cluster.peer_schedule(), + if fair { Schedule::Broadcast } else { Schedule::Strict } + ); } } diff --git a/rs/moq-room/MIGRATE.md b/rs/moq-room/MIGRATE.md index fab249cc52..93cef7a3bc 100644 --- a/rs/moq-room/MIGRATE.md +++ b/rs/moq-room/MIGRATE.md @@ -3,3 +3,7 @@ How to update your code for each breaking change, newest first. ## Unreleased + +### `chat::PRIORITY` is signed + +`chat::PRIORITY` is an `i8`, still 10. diff --git a/rs/moq-tokio/src/quic/driver/send_group.rs b/rs/moq-tokio/src/quic/driver/send_group.rs index 5ba873280a..3384ba971a 100644 --- a/rs/moq-tokio/src/quic/driver/send_group.rs +++ b/rs/moq-tokio/src/quic/driver/send_group.rs @@ -35,7 +35,11 @@ struct Inner { } impl SendGroup { - pub(crate) fn new(conn: Arc, domain: Option<&SendDomain>, priority: i64) -> Self { + pub(crate) fn new( + conn: Arc, + domain: Option<&SendDomain>, + priority: i64, + ) -> Self { let group = conn .state .lock("SendGroup::new") diff --git a/rs/moq-tokio/src/transport/grouped.rs b/rs/moq-tokio/src/transport/grouped.rs index 915ac0b935..1ad81a13ba 100644 --- a/rs/moq-tokio/src/transport/grouped.rs +++ b/rs/moq-tokio/src/transport/grouped.rs @@ -27,7 +27,11 @@ pub trait Grouped: web_transport_trait::Session { } /// Open a send group at `priority` in `domain`, or `None` when the backend has none. - fn send_group(&self, domain: Option<&Domain>, priority: i64) -> Option<::Group> { + fn send_group( + &self, + domain: Option<&Domain>, + priority: i64, + ) -> Option<::Group> { let _ = (domain, priority); None } diff --git a/rs/moq-uring/src/transport/local.rs b/rs/moq-uring/src/transport/local.rs index 340fc9c976..b02e3c9c68 100644 --- a/rs/moq-uring/src/transport/local.rs +++ b/rs/moq-uring/src/transport/local.rs @@ -72,7 +72,9 @@ impl poll::Session for Local { domain: Option<&poll::DomainOf>, priority: i64, ) -> Option::Group>> { - self.0.send_group(domain.map(|domain| &*domain.0), priority).map(Local::new) + self.0 + .send_group(domain.map(|domain| &*domain.0), priority) + .map(Local::new) } fn send_datagram( diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 1e51e61d5e..d2f0a80210 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed + +`TrackInfo.priority`, `Subscription.priority`, `FetchGroupOptions.priority`, and `GroupRequest.priority` are `Int8`, higher first, and 0 is the unset midpoint. +`TrackInfo.priority` defaulted to 127 and now defaults to 0. +To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. + ### JSON consumers yield `Json.Timed` (#5677) `Json.SnapshotConsumer` and `Json.StreamConsumer` yield `Json.Timed` (`value`, `timestampUs`) instead of the bare value; `timestampUs` is `nil` when untimed. diff --git a/swift/Sources/Moq/Dynamic.swift b/swift/Sources/Moq/Dynamic.swift index 8dc8c5539f..211d7157dc 100644 --- a/swift/Sources/Moq/Dynamic.swift +++ b/swift/Sources/Moq/Dynamic.swift @@ -85,7 +85,7 @@ public final class GroupRequest: Sendable { } /// The consumer's delivery priority for this fetch. - public var priority: UInt8 { + public var priority: Int8 { ffi.priority() } From 6dc7002aabffddec27b629b2876cd957a39eefb1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 20:43:52 -0700 Subject: [PATCH 04/11] refactor: narrow track priority to signed strict ordering Drop per-broadcast scheduling from this PR: session::Schedule, the send-domain plumbing in moq-net, moq-tokio and moq-uring, public moq-quic domains, FETCH domains, the relay's cluster.fair setting, and the draft text letting a relay scope priority to the broadcast. Ordering stays strict. Bump the recording format to version 3, whose .info stores the signed priority as written. Readers still accept version 2, converting its moq-lite byte; a writer refuses to append to one. Move the fairness scope to quest/m2/broadcast-fairness.md and delete the completed track-priority-scope quest. Co-Authored-By: Claude Opus 5.5 --- dart/moq/MIGRATE.md | 2 +- dart/moq_ffi/lib/src/uniffi_runtime.dart | 2 - doc/bin/relay/cluster.md | 16 - doc/bin/relay/config.md | 1 - drafts/draft-lcurley-moq-hang.md | 10 +- drafts/draft-lcurley-moq-lite.md | 4 - go/wrapper/MIGRATE.md | 2 +- js/hang/src/catalog/archive.ts | 2 +- js/hang/src/catalog/root.test.ts | 2 +- js/net/MIGRATE.md | 2 +- kt/moq/MIGRATE.md | 2 +- py/moq-rs/MIGRATE.md | 2 +- quest/m0/audit-quests.md | 6 +- quest/m1/README.md | 1 - quest/m1/egress-held-state.md | 8 +- quest/m1/quic/README.md | 4 +- quest/m1/quic/receive-memory.md | 2 +- quest/m1/subscribe-ranges/README.md | 1 - quest/m1/track-priority-scope.md | 130 ------- quest/m2/README.md | 1 + quest/m2/broadcast-fairness.md | 73 ++++ quest/m2/probe/simulcast-rung-disable.md | 4 +- quest/m3/ladder/README.md | 9 +- quest/m3/ladder/controller.md | 8 +- rs/hang/MIGRATE.md | 2 +- rs/hang/src/catalog/archive.rs | 4 +- rs/moq-archive/MIGRATE.md | 6 +- rs/moq-archive/src/error.rs | 4 +- rs/moq-archive/src/info.rs | 105 ++++-- rs/moq-archive/src/lib.rs | 6 +- rs/moq-archive/src/proof.rs | 2 +- rs/moq-archive/src/reader/tests.rs | 2 +- rs/moq-archive/src/segment.rs | 25 +- rs/moq-archive/src/store.rs | 15 +- rs/moq-c/MIGRATE.md | 2 +- rs/moq-ffi/MIGRATE.md | 2 +- rs/moq-ffi/src/transport/adapter.rs | 5 +- rs/moq-mux/MIGRATE.md | 2 +- rs/moq-mux/src/container/test_util.rs | 12 +- rs/moq-net/MIGRATE.md | 8 +- rs/moq-net/src/client.rs | 30 +- rs/moq-net/src/coding/writer.rs | 5 - rs/moq-net/src/ietf/adapter.rs | 12 +- rs/moq-net/src/ietf/publisher.rs | 7 +- rs/moq-net/src/lib.rs | 2 - rs/moq-net/src/lite/publisher.rs | 348 +++--------------- rs/moq-net/src/lite/session.rs | 10 - rs/moq-net/src/lite/test_transport.rs | 60 +-- rs/moq-net/src/model/group.rs | 7 - rs/moq-net/src/model/track.rs | 6 +- rs/moq-net/src/scheduler.rs | 135 ------- rs/moq-net/src/server.rs | 28 +- rs/moq-net/src/session.rs | 24 -- rs/moq-net/src/transport.rs | 41 +-- rs/moq-net/src/transport/memory.rs | 10 +- rs/moq-net/tests/integration/support/mock.rs | 6 +- rs/moq-quic/src/connection/mod.rs | 5 +- rs/moq-quic/src/connection/streams/mod.rs | 31 +- .../src/connection/streams/saturated.rs | 14 +- .../src/connection/streams/schedule.rs | 2 + rs/moq-quic/src/connection/streams/state.rs | 6 +- rs/moq-quic/src/lib.rs | 2 +- rs/moq-quic/src/tests/mod.rs | 4 +- rs/moq-relay/src/cluster.rs | 64 +--- rs/moq-relay/src/connection.rs | 3 +- rs/moq-relay/src/incoming.rs | 10 - rs/moq-relay/src/settings.rs | 3 - rs/moq-relay/src/uring.rs | 3 +- rs/moq-room/MIGRATE.md | 2 +- rs/moq-tokio/src/client.rs | 6 - rs/moq-tokio/src/quic/driver/connection.rs | 18 +- rs/moq-tokio/src/quic/driver/mod.rs | 2 +- rs/moq-tokio/src/quic/driver/send_group.rs | 61 +-- rs/moq-tokio/src/quic/web/session.rs | 17 +- rs/moq-tokio/src/server.rs | 7 - rs/moq-tokio/src/transport.rs | 16 +- rs/moq-tokio/src/transport/grouped.rs | 20 +- rs/moq-tokio/src/transport/owned.rs | 11 +- rs/moq-uring/src/quic/driver/connection.rs | 18 +- rs/moq-uring/src/quic/driver/group.rs | 57 +-- rs/moq-uring/src/quic/driver/mod.rs | 2 +- rs/moq-uring/src/quic/mod.rs | 2 +- rs/moq-uring/src/quic/web.rs | 13 +- rs/moq-uring/src/transport.rs | 10 +- rs/moq-uring/src/transport/adapter.rs | 29 +- rs/moq-uring/src/transport/local.rs | 16 +- rs/moq-wasm/src/transport/adapter.rs | 5 +- swift/MIGRATE.md | 2 +- 88 files changed, 410 insertions(+), 1278 deletions(-) delete mode 100644 quest/m1/track-priority-scope.md create mode 100644 quest/m2/broadcast-fairness.md delete mode 100644 rs/moq-net/src/scheduler.rs diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 38f68e8c2f..02d03a918f 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Priorities are signed +### Priorities are signed (#6142) `TrackInfo.priority`, `Subscription.priority`, and `FetchGroupOptions.priority` take `-128..=127`, higher first, and 0 is the unset midpoint. `TrackInfo.priority` defaulted to 127 and now defaults to 0. diff --git a/dart/moq_ffi/lib/src/uniffi_runtime.dart b/dart/moq_ffi/lib/src/uniffi_runtime.dart index 1eefdf6293..ab87a64ed3 100644 --- a/dart/moq_ffi/lib/src/uniffi_runtime.dart +++ b/dart/moq_ffi/lib/src/uniffi_runtime.dart @@ -8,7 +8,6 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; class UniffiInternalError implements Exception { @@ -248,7 +247,6 @@ mixin FfiConverterPrimitive on FfiConverter { @override T lower(T value) => value; } - Uint8List createUint8ListFromInt(int value) { int length = value.bitLength ~/ 8 + 1; if (length != 4 && length != 8) { diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index 39c6ac35e1..d0652a68f8 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -169,22 +169,6 @@ the maximum so new subscriptions go elsewhere while existing ones finish. A route's price is the publisher's production cost plus the link costs it crosses. A live publisher seeds 0, and caching never changes it. -## Fair links - -A link between relays carries every tenant's broadcasts, and by default it -sends the highest subscriber priority first, so one broadcast's viewers can -starve another's when the link congests. `fair = true` gives each broadcast an -equal share of the link instead, however many tracks or viewers it has, and -orders a broadcast's tracks by the publisher's priority alone. - -```toml -[cluster] -fair = true -``` - -It applies to the links this relay serves over QUIC or WebTransport on -moq-lite. Links over WebSocket, iroh, or moq-transport stay strict. - ## LAN discovery On a LAN there may be no one to list. `[cluster.lan]` advertises this relay diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 9b6b35776e..b0b93ce8f3 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -264,7 +264,6 @@ node = "https://us-west.example.com/" # This relay's own URL. connect_api = "https://api.example.com/peers" # Or fetch the peer list (JSON array of URLs and/or objects) live. token = "cluster.jwt" # JWT for dials without an inline ?jwt=. id = 12345 # Stable Hop ID across restarts. -# fair = true # Share peer links equally between broadcasts. [cluster.lan] # Find peers on the LAN over mDNS. enabled = true diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 576b331cf1..10fec06780 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -1155,15 +1155,16 @@ Each track has its own objects so a reader can fetch one rendition without downl ~~~ { - "version": 2, + "version": 3, "priority": 0, "timescale": 1000000 } ~~~ `version` and `priority` are required integers. -`version` identifies this recording format and MUST be 2. -`priority` and `timescale` have the meanings of moq-lite `TRACK_INFO` {{moql}}: `priority` is in the range 0 through 255, and `timescale` is in the range 1 through 9007199254740991, so JSON consumers can preserve it exactly. +`version` identifies this recording format and MUST be 3 when written. +A reader MAY also accept version 2, which differs only in storing `priority` as its moq-lite byte, 0 through 255, from which the reader subtracts 128. +`priority` and `timescale` have the meanings of moq-lite `TRACK_INFO` {{moql}}: `priority` is the Publisher Priority minus 128, in the range -128 through 127 with 0 for an unset priority, and `timescale` is in the range 1 through 9007199254740991, so JSON consumers can preserve it exactly. `timescale` is absent exactly when the track is untimed: its frames store no timestamps ({{recording-segments}}) and its records carry no `pts` or `duration` ({{timeline-records}}). A timeline track's object declares a `timescale` exactly when the track it indexes does. A reader MUST preserve integer values exactly. @@ -1182,7 +1183,7 @@ A segment object holds one record's frames: ~~~ Segment Object { - Version (i) = 2 + Version (i) = 3 Frame Start (i) Group Count (i) Group Table { @@ -1355,6 +1356,7 @@ This document has no IANA actions. ## moq-hang-04 {:numbered="false"} +- Recording format version 3: `.info` stores `priority` signed, -128 through 127 with 0 unset, instead of its moq-lite byte. A reader may still accept version 2. - A reader refuses a JSON frame in which an object repeats a member name. - Added the root `version` ({{catalog-version}}): the oldest draft that reads the catalog, which a consumer refuses when above its own. A `track` other than the entry's ID or a video `warmup` needs 4. Consumers MUST ignore unknown fields. - A data track marks a discontinuity with a group of one object with no bytes, since its empty payload is data. An object with no bytes on an audio or video track, or sharing its group, is malformed. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index bf463b39d0..60d0f1bad8 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -659,10 +659,6 @@ The `Subscriber Priority` is scoped to the connection and MAY change over the li The `Publisher Priority` is fixed for the lifetime of the Track (see [TRACK_INFO](#track-info)) and SHOULD be used only to resolve conflicts or ties. Both are a byte where higher is more urgent, and 128 is the midpoint an endpoint SHOULD use for a priority it was never given. -A relay serving many broadcasts to another relay MAY scope priority to the broadcast instead of the connection. -It then shares the connection's bandwidth equally between the broadcasts it serves, however many subscriptions each carries, and orders the streams within each broadcast by `Publisher Priority` alone, ignoring the `Subscriber Priority` it receives and not forwarding it upstream. -This keeps one broadcast's subscribers from starving another's, at the cost of the subscribers' preferences across that hop. - A conflict can occur when a relay tries to serve multiple downstream subscriptions from a single upstream subscription. The relay cannot pick any one subscriber's priority, so the upstream subscription SHOULD use the publisher priority instead of some combination of different subscriber priorities. Publisher priority is therefore mostly relevant on the upstream (origin-facing) leg of a relay; closer to the subscriber, the subscriber priority dominates. diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 67382420a0..ae982d6d21 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Priorities are signed +### Priorities are signed (#6142) `TrackInfo.Priority`, `Subscription.Priority`, and `FetchGroupOptions.Priority` are `int8`, and `GroupRequest.Priority()` returns one. Higher is first and the zero value is the unset midpoint, so a `TrackInfo` that leaves `Priority` zero now gets the default rather than the least urgent priority; drop any `Priority: 127` that asked for the midpoint. diff --git a/js/hang/src/catalog/archive.ts b/js/hang/src/catalog/archive.ts index bfb51d2366..f42ec46d53 100644 --- a/js/hang/src/catalog/archive.ts +++ b/js/hang/src/catalog/archive.ts @@ -11,7 +11,7 @@ const timescaleSchema = nonzeroU53Schema.check(z.lte(4_294_967_295)); /** * The recording object format advertised in {@link Archive.version} when a store is present. */ -export const ARCHIVE_VERSION = 2; +export const ARCHIVE_VERSION = 3; /** * Discovers each track's timeline and any durable archive. diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index e1fcd4560d..520db250ff 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -149,7 +149,7 @@ test("archive round-trips at the root", () => { timescale: 1000, replay: "recordings/clip", store: "https://objects.example/rec/", - version: 2, + version: ARCHIVE_VERSION, }); expect(JSON.stringify(parsed)).not.toContain('"timeline":'); }); diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index fd478350ca..efce3d160f 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Priorities are signed +### Priorities are signed (#6142) `Track.Info.priority`, `Track.Subscription.priority`, and a fetch's `priority` take `-128..=127`, higher first, and 0 is the unset midpoint; anything outside throws a `RangeError`. The track's default was 127 and the subscription's 0, the least urgent; both are 0 now. diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index deda766c57..9dbd0ce3d2 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Priorities are signed +### Priorities are signed (#6142) `TrackInfo.priority`, `Subscription.priority`, and `FetchGroupOptions.priority` are `Byte`, higher first, and 0 is the unset midpoint; write `priority = 3` instead of `3u`. `TrackInfo.priority` defaulted to 127 and now defaults to 0. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index d19b097604..94e43f81bf 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Priorities are signed +### Priorities are signed (#6142) `TrackInfo.priority`, `Subscription.priority`, and `FetchGroupOptions.priority` take `-128..=127`, higher first, and 0 is the unset midpoint. `TrackInfo.priority` defaulted to 127 and now defaults to 0. diff --git a/quest/m0/audit-quests.md b/quest/m0/audit-quests.md index 556e8afcf5..2cfa6e8bd4 100644 --- a/quest/m0/audit-quests.md +++ b/quest/m0/audit-quests.md @@ -32,7 +32,7 @@ Checkpoint, 2026-10-19, against `main` at `2e1ec2ebf`: - Retired datagram priority: #5770 (`79005b06d`) and #5872 (`8e3966cd3`) already contain the scheduler, publisher integration, saturation and eviction tests, fan-out benchmarks, and draft updates. Broadcast-domain - exposure remains owned by track-priority-scope. + exposure remains owned by [broadcast fairness](/quest/m2/broadcast-fairness.md). - Encoded the already-decided timestamp and E2EE naming-design prerequisites. Corrected stale paths, epoch discovery, accepted refresh recovery behavior, mobile priority, and the landed error, shard, benchmark @@ -73,7 +73,3 @@ settled. The unattended instruction was to skip uncertain choices: - [Catalog HLS](/quest/m1/archive/catalog-hls.md) names `.hls.z` tracks, while [compression migration](/quest/m1/compress/migrate.md) removes `.z` names. Their shared timeline edits need a consistent final name and order. -- [Priority scope](/quest/m1/track-priority-scope.md) still preserves old - archive priority encoding, while [archive](/quest/m1/archive/README.md) - replaces the old layout without migration. The ladder also assumes a - publisher-priority tiebreak that priority scope still asks to decide. diff --git a/quest/m1/README.md b/quest/m1/README.md index ffade19231..c1cd304f47 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -84,7 +84,6 @@ lands before the cut. - [Identity and recovery guide](/quest/m1/identity-guide.md) - explain pinned catalogs, lazy jobs, takeover, restart and version compatibility - [Generated C bindings](/quest/m1/c/README.md) - C generated from moq-ffi ships as `moq-c` 0.8.0 and replaces the hand-written libmoq - [moq-ffi reports why a first dial fails](/quest/m1/ffi-first-dial-failure.md) - OBS names the reason while the first dial retries, as moq-c already can -- [Scope track priority](/quest/m1/track-priority-scope.md) - priority orders one owner's streams, and a shared cluster session is fair across tenants - [Audio jitter target](/quest/m1/audio-jitter-target/README.md) - the playout target is default in both languages; a manual browser proof and a native trace replay remain - [A/V clock](/quest/m1/av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles - [Publish channel count](/quest/m1/publish-audio-channel-count.md) - forcing a channel count on an Audio.Capture stops costing the subscriber gaps of silence, and the media fixture's cold-start audio-tone flake is fixed at its cause diff --git a/quest/m1/egress-held-state.md b/quest/m1/egress-held-state.md index d8ec6f6539..fed2c37f38 100644 --- a/quest/m1/egress-held-state.md +++ b/quest/m1/egress-held-state.md @@ -63,9 +63,9 @@ Recommended directions (the implementer settles the shape): to open a stream only when the session can grant one and then pick the best group still worth sending at that moment, so a parked backlog lives in the cache (already billed and evicted) rather than in per-subscriber machines. - Whatever the shape, cross-subscription priority and per-broadcast fairness - ([Scope track priority](/quest/m1/track-priority-scope.md)) must still - decide who gets the next stream. + Whatever the shape, cross-subscription priority, and later per-broadcast + fairness ([Broadcast fairness](/quest/m2/broadcast-fairness.md)), must still decide who gets the next + stream. - A parked group that can no longer be served (aborted, evicted, expired) leaves at once and is named to the subscriber where the version can, through SUBSCRIBE_DROP. @@ -113,7 +113,7 @@ none on the wire; possibly a relay default). - [Bound incomplete messages](/quest/m1/quic/receive-memory.md) - the ingress counterpart: a receiver withholds QUIC credit to bound what it holds, and this quest bounds what the sender holds while it does - [Rust request credit](/quest/m1/rs-request-credit.md) - requests fail fast without stream credit; publisher group streams are left to this quest -- [Scope track priority](/quest/m1/track-priority-scope.md) - who gets the next stream across subscriptions and tenants +- [Broadcast fairness](/quest/m2/broadcast-fairness.md) - who gets the next stream across broadcasts and tenants - [Public serving](/quest/m1/public-model/serve.md) - makes group expiry while serving public; coordinate the publisher serve loops with it - [Treat network input as untrusted](https://github.com/moq-dev/moq/pull/5961) - the rule this quest applies - [Resumed group head](https://github.com/moq-dev/moq/pull/5958) - the audit that found this, and bounded held heads by the cache pool diff --git a/quest/m1/quic/README.md b/quest/m1/quic/README.md index 5253f7be4f..bed115ec2d 100644 --- a/quest/m1/quic/README.md +++ b/quest/m1/quic/README.md @@ -74,8 +74,8 @@ cache limits remain separate; bitrate pacing composes with this memory cap. - [qmux on the QUIC stream state machine](/quest/m1/quic-qmux.md) - the m1 qmux port onto `moq-quic`'s stream state -- [Scope track priority](/quest/m1/track-priority-scope.md) - the - per-broadcast fairness policy on cluster sessions +- [Broadcast fairness](/quest/m2/broadcast-fairness.md) - the + per-broadcast fairness policy on cluster sessions, a consumer for send domains - [Native probing](/quest/m2/probe/native.md) - moq-quic retransmits in-flight data during ProbeBW_UP to probe up to a session's target - [L4S on the backbone](/quest/m3/quic-ecn.md) - an ECT(1) option in `moq-quic`, an `ecn` config knob, and a dualpi2 measurement - [Careful resume on reconnect](/quest/m3/quic-careful-resume.md) - a redial starts at the previous connection's rate diff --git a/quest/m1/quic/receive-memory.md b/quest/m1/quic/receive-memory.md index 476565e943..29b7a3e221 100644 --- a/quest/m1/quic/receive-memory.md +++ b/quest/m1/quic/receive-memory.md @@ -105,5 +105,5 @@ separate guide quest is needed. ## Related - [Unbounded groups](/quest/m2/unbounded-groups/README.md) - removes the frame cap; frame payload credit becomes per segment -- [Scope track priority](/quest/m1/track-priority-scope.md) - broadcast fairness domains and subscription priority semantics +- [Broadcast fairness](/quest/m2/broadcast-fairness.md) - broadcast fairness domains on the same sessions - [QUIC bitrate caps](/quest/m2/rate-quic.md) - pace credit grants inside the independently bounded memory allowance diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index 05b6dbeb7f..3bac728704 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -103,5 +103,4 @@ every sequence delivered or dropped. ## Related -- [Track priority scope](/quest/m1/track-priority-scope.md) - group order within a track now follows the subscription - [IETF fill timeout](/quest/m1/ietf-fill-timeout.md) - bounds upstream waiting independently of content age diff --git a/quest/m1/track-priority-scope.md b/quest/m1/track-priority-scope.md deleted file mode 100644 index 9fb29bb5cc..0000000000 --- a/quest/m1/track-priority-scope.md +++ /dev/null @@ -1,130 +0,0 @@ -# [XL] Scope and sign track priority - -## Goal - -Priority orders streams only within one scheduling domain: the streams a single -party owns both ends of, the first mile from publisher to ingest relay and the -last mile from edge relay to viewer. On a shared relay-to-relay session the -domain is the broadcast: when the relay enables it, broadcasts share the link -fairly and priority orders streams only inside each broadcast, so one -customer's viewers asking for 255 never starve another customer's broadcast. - -In the same API break, every priority becomes an `i8`, higher first, with 0 as -the unset midpoint: `track::Info`, `Subscription`, and `group::Fetch` in Rust, -their JS counterparts, moq-ffi, moq-c, and every wrapper. Nobody has to know -that 127 is the middle of a `u8`. The wire stays a byte. - -## Plan - -Where the model stands today, all in `rs/moq-net`: - -- One `PriorityQueue` per session, shared by the moq-lite and moq-transport - publishers, a strict global sort over every in-flight group on the - connection: a subscription at - priority 200 pre-empts one at 100 indefinitely, and only the top 255 groups - get a distinct rank. -- `Priority` already ranks a group by its track priority, then its subscription - id, then group within that subscription in its order (`priority.rs`). JS - packs track priority and the group's position within its own subscription - into one send order (`js/net/src/priority.ts`), so two tracks at equal - priority interleave rather than one draining first. -- Group order within a track follows each subscription's `order` (#5385), - newest first by default. -- A relay forwards the max of its downstream subscriber priorities upstream - (`model/subscription.rs`, `lite/subscriber.rs`), never the publisher's track - priority, so one viewer asking for 255 raises that track above every other - tenant's on the cluster link (`rs/moq-relay/src/cluster.rs`: one - bidirectional session per peer pair carries every broadcast). The draft - already says the upstream leg SHOULD use the publisher priority. - -Decided 2026-09-19: the domain on a relay-to-relay session is the broadcast, -keyed by its path, and it is a relay setting (`cluster.fair = true`), off by -default so a single-tenant deployment keeps strict priority across the link. -A tenant-keyed domain (one bucket per customer, however many broadcasts they -run) needs the tenant identity from the auth line first and is a later -refinement of the same tier, not a different design. Opening more -subscriptions or broadcasts must never buy more bandwidth than the bucket -allows. - -Decided 2026-10-08: the signed priority type lands here, so the priority API -breaks once instead of twice. Subscribe ranges owns group order, so this quest -neither forbids an order knob nor edits the doc's Order row. - -Direction to settle in the draft first, then the code: - -- On the last mile the viewer owns the whole session, so its audio and video - subscriptions share one domain and the wire is unchanged. On a relay-to-relay - session the relay knows every subscription's broadcast, so no carrier is - needed on the wire; the draft only has to say that a relay MAY scope - priority to the broadcast. -- On a relay-to-relay session with fairness on, ordering across broadcasts is - byte-fair (the scheduler's fair tier, one domain per broadcast and one send group - per subscription inside that domain), the - publisher's track priority orders streams within a broadcast, and - downstream subscriber priorities are not forwarded. Attribute FETCH streams - to a domain the same way: cache-miss and history fetches carry Subscriber - Priority outside any subscription, so leaving them outside every bucket - bypasses tenant isolation; settle their carrier beside the subscription one, - or narrow the goal to subscription delivery. -- Rank by track priority, then subscription, then the subscription's own group - order (`Subscription::order`, newest first by default). -- Decide whether the publisher's `track::Info::priority` breaks a tie - between equal subscriber priorities in `Priority::cmp` - (`rs/moq-net/src/priority.rs`). The - [ladder controller](/quest/m3/ladder/controller.md), now in m3, wants that - tiebreak; this quest owns the answer and the code change, so the controller - only consumes it (2026-10-06 audit). Either way, make the docs agree: - `Info::priority` in `rs/moq-net/src/model/track.rs` already claims the - tiebreak, and the allocator doc in `rs/moq-net/src/model/bandwidth.rs` - presents send order as unrelated. -- A per-session cap on distinct ranks is a scheduling detail; whatever replaces - the 255-entry sort must stay O(log n) per group under chat-shaped churn. -- moq-quic already has the fair tier: send domains sharing bytes by deficit - round robin, with groups inside them. They are crate-private (exercised only - by its tests) until this quest gives them a consumer. Exposing them also - gives moq-net's `poll::Session::send_group(priority)` a domain, so land that - in the same trait break and implementers break once. - -Signed priority, decided with the maintainer: - -- moq-lite carries `p + 128` (flip the top bit). The mapping is one-to-one - and keeps order, so a given byte means what it means today; only the API - number changes. The default becomes byte 128. -- IETF carries `127 - p`. Both mappings are one-to-one over the whole `i8` - range, with no saturation. An unset priority goes out as IETF 127, not the - draft's usual 128, and an absent IETF priority decodes to 0, the unset - default. Pin both ends in tests. -- Keep one urgency on both wires (#4273), so the IETF byte is always `255 -` - the lite byte. Mapping IETF as `128 - p` to hit the draft's 128 would break - that, which is why the default is one step off the draft there. -- hang's built-in priorities (`hang::catalog::PRIORITY`, today all below the - 127 default) move above 0, so hang media outranks a track that never set - one. Rust and JS keep matching values; the spacing is the implementer's call. -- moq-c's `moq_track_info.priority` has no presence flag, so a zeroed struct - means least urgent today. With `i8`, zero is the default. -- Look for anything that does arithmetic on priority (the lite send queue, JS - send-order packing, the bandwidth allocator, the relay's max-of-subscribers) - and keep its ordering, not just its type. -- moq-archive's `Info::priority` follows. Its version-1 `.info` stores the - `u8`, so existing recordings must keep their meaning: store the moq-lite - byte (`p + 128`) or bump the format version, never reinterpret silently. -- The moxygen default (127, #4273) already shipped, so the default byte moves - a second time. Add an upgrade note to the release's upgrade page saying why. - Rejected: a mapping that keeps today's bytes (lite byte = p + 127). - -Prove scoping with the existing lite publisher tests extended to two -broadcasts on one session with fairness on: neither starves, a downstream 255 -does not change the upstream order, and with fairness off the strict order is -unchanged. The io_uring workers apply the same setting. Move -`doc/concept/moq-lite.md` (the 0..255 knob) and `doc/concept/standard.md` -(IETF 128 maps to 127) to the new range and mapping. - -Public API impact: `u8` priority fields become `i8` in every language, and -`Subscribe.priority` on a cluster session changes meaning. Wire impact: none -in format, but the default byte moves from 127 to 128 on moq-lite and from 128 -to 127 on IETF. Report both with the draft change. - -## Related - -- [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - owns the group - order a subscription asks for diff --git a/quest/m2/README.md b/quest/m2/README.md index 502e6f8a43..715793eba2 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -34,6 +34,7 @@ ranked just below deterministic TS legs, which it requires. - [Poll-native async](/quest/m2/ffi-poll-migrate.md) - moq-ffi's pure-kio async exports poll their state directly instead of going through `ffi::Task` - [Foreign poll for hot reads](/quest/m2/ffi-poll-protocol.md) - the fork's generators give each wrapper a sync poll plus a registered waker for track and group reads, so a ready read skips the event-loop round trip and a cancel never loses a frame - [QoS](/quest/m2/qos/README.md) - broadcast health: relay starvation and timeliness histograms +- [Broadcast fairness](/quest/m2/broadcast-fairness.md) - broadcasts share a cluster link fairly, so one tenant's priority never starves another's - [Plan: watch worker](/quest/m2/plan-watch-worker.md) - prototype an invisible page worker against app-spawned workers, and land the jank harness that decides - [Watch worker](/quest/m2/watch-worker.md) - watch playback runs in a worker onto an OffscreenCanvas, so main-thread jank never stalls video or audio - [Vulkan Video encode on AMD](/quest/m2/vulkan-encode.md) - H.264 and H.265 from an external Vulkan image on RADV, hand-rolled on ash, proven on an RX 9070 diff --git a/quest/m2/broadcast-fairness.md b/quest/m2/broadcast-fairness.md new file mode 100644 index 0000000000..82db181627 --- /dev/null +++ b/quest/m2/broadcast-fairness.md @@ -0,0 +1,73 @@ +# [L] Fair share across broadcasts on cluster links + +## Goal + +On a shared relay-to-relay session, broadcasts share the link fairly and +priority orders streams only inside each broadcast, so one tenant's viewers +asking for the highest priority never starve another tenant's broadcast. +Opening more subscriptions or broadcasts must never buy more bandwidth than +the bucket allows. Single-tenant deployments and the first and last mile keep +strict priority across the session, as today. + +## Plan + +Needs a fresh `/quest-plan` before starting: there is no settled design. +Revisit once [qmux on the QUIC stream state machine](/quest/m1/quic-qmux.md) +gives every transport moq-quic's scheduler, and once the auth line carries a +tenant identity. + +Today every session schedules strictly: one `PriorityQueue` or one moq-quic +send group per subscription, ranked by the subscriber's priority, then the +publisher's track priority, then subscription id +(`rs/moq-net/src/priority.rs`). A relay forwards the max of its downstream +subscriber priorities upstream (`model/subscription.rs`), and +`rs/moq-relay/src/cluster.rs` runs one bidirectional session per peer pair +carrying every broadcast, so a viewer asking for 127 raises its track above +every other tenant's on the cluster link. + +Direction carried over from 2026-09-19, not re-confirmed: + +- The domain on a relay-to-relay session is the broadcast, keyed by its path, + behind a relay setting (`cluster.fair`), off by default. +- A tenant-keyed domain (one bucket per customer, however many broadcasts they + run) needs the tenant identity from the auth line and refines the same tier. +- moq-quic already has the fair tier: send domains sharing bytes by deficit + round robin, with groups inside them, crate-private until a consumer lands. + Exposing them gives `transport::poll::Session::send_group` a domain, a + trait break for every implementer. +- The draft would say a relay MAY scope priority to the broadcast on a + relay-to-relay connection; no carrier is needed on the wire. + +Dropped 2026-10-20 from +[moq-dev/moq#6142](https://github.com/moq-dev/moq/pull/6142), which landed +only signed priorities and the publisher-priority tiebreak. Its first +revision prototyped a send domain per broadcast in the lite publisher +(`session::Schedule { Strict, Broadcast }`, `poll::Session::send_domain`, +public moq-quic domains, `cluster.fair`), falling back to strict on a +transport without domains. The maintainer: "IDK I feel like we don't have a +good plan here". Open questions it left: + +- The IETF publisher schedules strictly; it needs the same domains. +- Tenant-keyed domains instead of, or above, broadcast domains. +- FETCH placement: cache-miss and history fetches carry Subscriber Priority + outside any subscription, so leaving them outside every bucket bypasses + isolation. +- Subscriber aggregate forwarding: under a fair schedule the downstream max + should stop being forwarded upstream at its source, not just be ignored by + the next fair hop. +- Transports without send domains (WebSocket, iroh, today's qmux) and + moq-transport sessions: refuse, or degrade to strict. +- A cheaper capability probe than opening and dropping a domain. + +Prove it with two broadcasts on one cluster session over a bufferbloated +link: neither starves, a downstream 127 does not change the upstream order, +and with fairness off the strict order is unchanged. + +## Required + +- [qmux on the QUIC stream state machine](/quest/m1/quic-qmux.md) - every transport shares moq-quic's scheduler, so fairness has one implementation + +## Related + +- [Egress held state](/quest/m1/egress-held-state.md) - whatever replaces parked group streams must still decide who gets the next stream across broadcasts +- [Bound incomplete messages](/quest/m1/quic/receive-memory.md) - the receive-side counterpart on the same moq-quic sessions diff --git a/quest/m2/probe/simulcast-rung-disable.md b/quest/m2/probe/simulcast-rung-disable.md index c723ab1496..821a3b6d13 100644 --- a/quest/m2/probe/simulcast-rung-disable.md +++ b/quest/m2/probe/simulcast-rung-disable.md @@ -42,8 +42,8 @@ grant. reservation, so it keeps probing for the stopped rung and the estimate can rise to fund it. No padding handle or burst schedule is driven from `Rung`. - Assign descending track priority down the renditions so the allocator - fills lower rungs first; [Scope track priority](/quest/m1/track-priority-scope.md) - settles how that number also orders sends. + fills lower rungs first; the same number breaks ties in send order + between subscriptions at one subscriber priority. - OBS: each OBS video encoder is its own rendition (`video_tracks` in `cpp/obs/src/moq-output.cpp`), so apply the rule per encoder through the generated C++ package, on top of the per-encoder diff --git a/quest/m3/ladder/README.md b/quest/m3/ladder/README.md index bd37a8deeb..024bdbec99 100644 --- a/quest/m3/ladder/README.md +++ b/quest/m3/ladder/README.md @@ -34,12 +34,9 @@ What remains is the publisher side. The allocator estimate by `track::Info::priority`, filling a tier before the next sees a bit and splitting max-min fair within one. A controller that assigns descending priorities down the ladder gets correct allocation from that -alone. Send order is a different number today: `Priority::cmp` -(`rs/moq-net/src/priority.rs`) ranks first by `Priority.track`, which is -the subscriber's priority from SUBSCRIBE (`msg.priority` in -`rs/moq-net/src/lite/publisher.rs`), not the publisher's `Info::priority`. -[Scope track priority](/quest/m1/track-priority-scope.md) owns making the -publisher's number the tiebreak after it, so the same number decides what to +alone. Send order ranks by the subscriber's priority from SUBSCRIBE first +and the publisher's `Info::priority` after it (`Priority::cmp` in +`rs/moq-net/src/priority.rs`, [moq-dev/moq#6142](https://github.com/moq-dev/moq/pull/6142)), so the same number decides what to produce and, among equal subscriber priorities, what to send first; the controller only consumes that tiebreak. diff --git a/quest/m3/ladder/controller.md b/quest/m3/ladder/controller.md index 9f61396bc0..c1d26cd668 100644 --- a/quest/m3/ladder/controller.md +++ b/quest/m3/ladder/controller.md @@ -33,9 +33,7 @@ stamped `PRIORITY.video` today (`rs/moq-transcode/src/rung.rs`, before the next sees a bit, so that alone protects lower rungs' allocation. For send order, consume the publisher-priority tiebreak in `Priority::cmp` -that [Scope track priority](/quest/m1/track-priority-scope.md) settles; that -quest owns the code change and the doc fixes (decided in the 2026-10-06 -audit). The `BitrateUnsupported` fallback leans on it: without it, an +([moq-dev/moq#6142](https://github.com/moq-dev/moq/pull/6142)). The `BitrateUnsupported` fallback leans on it: without it, an encoder that cannot retune degrades the whole ladder equally instead of protecting the bottom. A subscriber asking for a higher rendition ahead of a lower one still gets what it asked for. @@ -67,10 +65,6 @@ bandwidth input preserving existing behavior exactly; and for send order, equal subscriber priority where the lower rung wins, conflicting subscriber priority where the subscriber's order wins, and a custom ladder order. -## Required - -- [Scope track priority](/quest/m1/track-priority-scope.md) - settles the priority ranking the controller assigns before the encoder side reads it - ## Related - [Encoder feedback](/quest/m3/stats-encoder-feedback.md) - viewer stalls for `moq import`; its `moq transcode --echo` leg, split out if revived, feeds this controller diff --git a/rs/hang/MIGRATE.md b/rs/hang/MIGRATE.md index 23d8e994c3..2bfc7e2da1 100644 --- a/rs/hang/MIGRATE.md +++ b/rs/hang/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Priorities are signed +### Priorities are signed (#6142) `catalog::Priorities` fields and `container::track_info`'s priority are `i8`, higher first, with 0 unset. `PRIORITY` keeps its values (100, 90, 80, 60), which now sit above the unset default instead of below it, so their moq-lite bytes move from 100..60 to 228..188. diff --git a/rs/hang/src/catalog/archive.rs b/rs/hang/src/catalog/archive.rs index 0d83ea0e1e..d1fa5d5627 100644 --- a/rs/hang/src/catalog/archive.rs +++ b/rs/hang/src/catalog/archive.rs @@ -64,7 +64,7 @@ pub struct Archive { impl Archive { /// The recording object format advertised in [`version`](Self::version). - pub const VERSION: u32 = 2; + pub const VERSION: u32 = 3; /// The default timeline timescale, milliseconds. pub const fn default_timescale() -> u32 { @@ -121,7 +121,7 @@ mod test { let json = serde_json::to_string(&archive).unwrap(); assert_eq!( json, - r#"{"timelines":{"video":{"track":"video.timeline.z","duration":4000}},"timescale":1000,"replay":"recordings/clip","store":"https://objects.example/rec/","version":2}"# + r#"{"timelines":{"video":{"track":"video.timeline.z","duration":4000}},"timescale":1000,"replay":"recordings/clip","store":"https://objects.example/rec/","version":3}"# ); assert_eq!(serde_json::from_str::(&json).unwrap(), archive); } diff --git a/rs/moq-archive/MIGRATE.md b/rs/moq-archive/MIGRATE.md index 8003d2b923..9b1aad0984 100644 --- a/rs/moq-archive/MIGRATE.md +++ b/rs/moq-archive/MIGRATE.md @@ -4,10 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased -### `Info::priority` is signed +### Recording format version 3 stores a signed priority (#6142) `Info::priority`, `Info::new`, and `Error::Priority` use `i8`, higher first, with 0 unset. -`.info` still stores the moq-lite byte, `priority + 128`, so existing recordings keep their meaning: a stored 127 reads as -1. +`VERSION` is 3, whose `.info` stores that `i8` as written; version 2 stored the moq-lite byte, `priority + 128`. +Readers still read version 2 recordings, converting the byte, so a stored 127 reads as -1; a decoded version 2 `Info` keeps `version: 2` and refuses to `encode`. +A writer refuses to append to a version 2 recording with `Error::Version(2)`, so start a new prefix, as for `Error::EpochMismatch`. ### Untimed tracks record untimed (#5720) diff --git a/rs/moq-archive/src/error.rs b/rs/moq-archive/src/error.rs index 75ae9e3500..c88468999d 100644 --- a/rs/moq-archive/src/error.rs +++ b/rs/moq-archive/src/error.rs @@ -10,8 +10,8 @@ pub enum Error { #[error("not found: {0}")] NotFound(String), - /// The recording format version is not the current one. - #[error("unknown version {0}")] + /// The recording format version is unknown, or older than the current one when writing. + #[error("unsupported version {0}")] Version(u64), /// A group ID, segment ID, or frame timestamp is outside 0 through 2^53 - 1. diff --git a/rs/moq-archive/src/info.rs b/rs/moq-archive/src/info.rs index 1513f95344..8ada3963b4 100644 --- a/rs/moq-archive/src/info.rs +++ b/rs/moq-archive/src/info.rs @@ -2,19 +2,16 @@ use bytes::Bytes; use serde::{Deserialize, Serialize}; use crate::path::check_id; -use crate::{Error, Result, VERSION}; +use crate::{Error, Result, VERSION, VERSION_MIN}; /// Immutable track properties stored at `/.info`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] +#[serde(try_from = "Stored")] pub struct Info { - /// Recording format version; must be [`VERSION`]. + /// Recording format version: [`VERSION`], or 2 for a recording read from before + /// priorities were signed, which only a reader accepts. pub version: u64, /// The publisher's track priority, higher first. - /// - /// Stored as its moq-lite byte, `priority + 128`, which is what recordings made before - /// priorities were signed stored, so they keep their meaning. - #[serde(with = "priority")] pub priority: i8, /// Units per second for this track's timestamps, 1 through 2^53 - 1, or `None` when the track /// is untimed and its frames store no timestamps. @@ -44,8 +41,11 @@ impl Info { self } - /// Encode as compact JSON. + /// Encode as compact JSON, refusing any version but the current one. pub fn encode(&self) -> Result { + if self.version != VERSION { + return Err(Error::Version(self.version)); + } self.validate()?; Ok(Bytes::from(serde_json::to_vec(self)?)) } @@ -58,7 +58,7 @@ impl Info { } fn validate(&self) -> Result<()> { - if self.version != VERSION { + if !(VERSION_MIN..=VERSION).contains(&self.version) { return Err(Error::Version(self.version)); } if let Some(timescale) = self.timescale { @@ -71,16 +71,34 @@ impl Info { } } -/// The priority as its moq-lite byte. -mod priority { - use serde::{Deserialize, Deserializer, Serializer}; +/// `.info` as stored, before its priority is read in its version's encoding. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Stored { + version: u64, + priority: i64, + #[serde(default)] + timescale: Option, + #[serde(default, deserialize_with = "epoch::deserialize")] + epoch: Option, +} - pub fn serialize(priority: &i8, serializer: S) -> Result { - serializer.serialize_u8((*priority as u8) ^ 0x80) - } +impl TryFrom for Info { + type Error = String; - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - Ok((u8::deserialize(deserializer)? ^ 0x80) as i8) + fn try_from(stored: Stored) -> std::result::Result { + let priority = match stored.version { + // Version 2 stored the moq-lite byte, `priority + 128`. + 2 => u8::try_from(stored.priority).map(|byte| (byte ^ 0x80) as i8), + _ => i8::try_from(stored.priority), + } + .map_err(|_| format!("priority {} is out of range", stored.priority))?; + Ok(Self { + version: stored.version, + priority, + timescale: stored.timescale, + epoch: stored.epoch, + }) } } @@ -112,7 +130,7 @@ mod tests { fn roundtrip() { let info = Info::new(0, Some(1_000_000)).unwrap(); let bytes = info.encode().unwrap(); - assert_eq!(bytes.as_ref(), br#"{"version":2,"priority":128,"timescale":1000000}"#); + assert_eq!(bytes.as_ref(), br#"{"version":3,"priority":0,"timescale":1000000}"#); assert_eq!(Info::decode(&bytes).unwrap(), info); } @@ -120,38 +138,57 @@ mod tests { fn an_untimed_track_omits_its_timescale() { let info = Info::new(3, None).unwrap(); let bytes = info.encode().unwrap(); - assert_eq!(bytes.as_ref(), br#"{"version":2,"priority":131}"#); + assert_eq!(bytes.as_ref(), br#"{"version":3,"priority":3}"#); assert_eq!(Info::decode(&bytes).unwrap(), info); } #[test] fn whitespace_and_member_order_do_not_affect_equality() { - let info = Info::decode(br#"{"timescale":1000,"priority":7,"version":2}"#).unwrap(); + let info = Info::decode(br#"{"timescale":1000,"priority":7,"version":3}"#).unwrap(); assert_eq!( info, - Info::decode(br#"{ "version": 2, "priority": 7, "timescale": 1000 }"#).unwrap() + Info::decode(br#"{ "version": 3, "priority": 7, "timescale": 1000 }"#).unwrap() ); - assert_eq!(info.priority, -121); + assert_eq!(info.priority, 7); assert_eq!(info.timescale, Some(1000)); } #[test] - fn priority_is_stored_as_its_lite_byte() { - for (priority, byte) in [(i8::MIN, 0), (-1, 127), (0, 128), (i8::MAX, 255)] { + fn priority_is_stored_signed() { + for priority in [i8::MIN, -1, 0, i8::MAX] { let info = Info::new(priority, None).unwrap(); - let json = format!(r#"{{"version":2,"priority":{byte}}}"#); + let json = format!(r#"{{"version":3,"priority":{priority}}}"#); assert_eq!(info.encode().unwrap().as_ref(), json.as_bytes()); assert_eq!(Info::decode(json.as_bytes()).unwrap(), info); } - assert!(matches!( - Info::decode(br#"{"version":2,"priority":256}"#), - Err(Error::Json(_)) - )); + for priority in [-129, 128] { + let json = format!(r#"{{"version":3,"priority":{priority}}}"#); + assert!( + matches!(Info::decode(json.as_bytes()), Err(Error::Json(_))), + "{priority}" + ); + } + } + + /// Version 2 stored the priority as its moq-lite byte, `priority + 128`, so an old + /// recording keeps its meaning; nothing writes version 2 any more. + #[test] + fn a_version_2_info_reads_its_lite_byte() { + for (byte, priority) in [(0, i8::MIN), (127, -1), (128, 0), (255, i8::MAX)] { + let json = format!(r#"{{"version":2,"priority":{byte},"timescale":1000}}"#); + let info = Info::decode(json.as_bytes()).unwrap(); + assert_eq!((info.version, info.priority, info.timescale), (2, priority, Some(1000))); + assert!(matches!(info.encode(), Err(Error::Version(2)))); + } + for byte in [-1, 256] { + let json = format!(r#"{{"version":2,"priority":{byte}}}"#); + assert!(matches!(Info::decode(json.as_bytes()), Err(Error::Json(_))), "{byte}"); + } } #[test] fn unknown_version_is_refused() { - for version in [1, 3] { + for version in [1, VERSION + 1] { let json = format!(r#"{{"version":{version},"priority":0,"timescale":1000}}"#); assert!(matches!(Info::decode(json.as_bytes()), Err(Error::Version(v)) if v == version)); } @@ -164,11 +201,11 @@ mod tests { let bytes = info.encode().unwrap(); assert_eq!( bytes.as_ref(), - br#"{"version":2,"priority":128,"timescale":1000,"epoch":"01900000-0000-7000-8000-000000000001"}"# + br#"{"version":3,"priority":0,"timescale":1000,"epoch":"01900000-0000-7000-8000-000000000001"}"# ); assert_eq!(Info::decode(&bytes).unwrap(), info); assert!(matches!( - Info::decode(br#"{"version":2,"priority":0,"timescale":1000,"epoch":"not-a-uuid"}"#), + Info::decode(br#"{"version":3,"priority":0,"timescale":1000,"epoch":"not-a-uuid"}"#), Err(Error::Json(_)) )); } @@ -184,11 +221,11 @@ mod tests { #[test] fn unknown_members_and_missing_fields_are_refused() { assert!(matches!( - Info::decode(br#"{"version":2,"priority":0,"timescale":1,"extra":true}"#), + Info::decode(br#"{"version":3,"priority":0,"timescale":1,"extra":true}"#), Err(Error::Json(_)) )); assert!(matches!( - Info::decode(br#"{"version":2,"timescale":1}"#), + Info::decode(br#"{"version":3,"timescale":1}"#), Err(Error::Json(_)) )); } diff --git a/rs/moq-archive/src/lib.rs b/rs/moq-archive/src/lib.rs index 94ec612132..a9507f7113 100644 --- a/rs/moq-archive/src/lib.rs +++ b/rs/moq-archive/src/lib.rs @@ -43,7 +43,11 @@ pub use store::Store; pub use writer::Writer; /// Recording format version written into `.info` and the binary envelope. -pub const VERSION: u64 = 2; +pub const VERSION: u64 = 3; + +/// The oldest recording format version a reader accepts. Version 2 stored `.info`'s +/// priority as its moq-lite byte and is otherwise identical. +pub(crate) const VERSION_MIN: u64 = 2; /// Largest group, segment, or timestamp value the format allows (`2^53 - 1`). pub const ID_MAX: u64 = (1 << 53) - 1; diff --git a/rs/moq-archive/src/proof.rs b/rs/moq-archive/src/proof.rs index ed3eff5253..e92cff417c 100644 --- a/rs/moq-archive/src/proof.rs +++ b/rs/moq-archive/src/proof.rs @@ -204,7 +204,7 @@ async fn recordings_are_byte_identical_on_every_backend() { assert_eq!(expected.keys().cloned().collect::>(), layout()); assert_eq!( &expected["rec/video%2F360p/.info"][..], - br#"{"version":2,"priority":128,"timescale":1000}"# + br#"{"version":3,"priority":0,"timescale":1000}"# ); let dir = tempfile::tempdir().unwrap(); diff --git a/rs/moq-archive/src/reader/tests.rs b/rs/moq-archive/src/reader/tests.rs index 3cf641605e..e906f6ec4a 100644 --- a/rs/moq-archive/src/reader/tests.rs +++ b/rs/moq-archive/src/reader/tests.rs @@ -645,7 +645,7 @@ async fn a_track_without_usable_info_is_not_found() { archive .raw( &Key::info("future").unwrap(), - br#"{"version":3,"priority":0,"timescale":1000}"#, + br#"{"version":4,"priority":0,"timescale":1000}"#, ) .await; diff --git a/rs/moq-archive/src/segment.rs b/rs/moq-archive/src/segment.rs index 9f98a11971..7f69c463e0 100644 --- a/rs/moq-archive/src/segment.rs +++ b/rs/moq-archive/src/segment.rs @@ -3,7 +3,7 @@ use hang::timeline::Position; use moq_net::varint; use crate::path::check_id; -use crate::{Error, Result, VERSION}; +use crate::{Error, Result, VERSION, VERSION_MIN}; /// One group's stored frames in a segment object, in sequence order. #[derive(Debug, Clone, PartialEq, Eq)] @@ -116,11 +116,13 @@ impl Object { /// Decode a complete table before slicing any payload, and refuse an unknown version. /// + /// Version 2 objects are identical to the current ones. + /// /// `timed` is whether the track's `.info` declares a timescale, which says whether the table /// stores a timestamp per frame. pub fn decode(mut buf: impl Buf, timed: bool) -> Result { let version = read_varint(&mut buf)?; - if version != VERSION { + if !(VERSION_MIN..=VERSION).contains(&version) { return Err(Error::Version(version)); } @@ -368,7 +370,7 @@ mod tests { .unwrap(); let mut buf = &bytes[..]; - assert_eq!(read_varint(&mut buf).unwrap(), 2); // version + assert_eq!(read_varint(&mut buf).unwrap(), VERSION); // version assert_eq!(read_varint(&mut buf).unwrap(), 0); // frame start assert_eq!(read_varint(&mut buf).unwrap(), 3); // group count assert_eq!(read_varint(&mut buf).unwrap(), 5); // absolute @@ -496,7 +498,7 @@ mod tests { #[test] fn unknown_version_is_refused() { // Version 1 objects held whole groups under range-named keys. - for version in [1, 3] { + for version in [1, VERSION + 1] { let mut bytes = BytesMut::new(); write_varint(&mut bytes, version).unwrap(); write_varint(&mut bytes, 1).unwrap(); @@ -504,6 +506,21 @@ mod tests { } } + #[test] + fn a_version_2_object_decodes_unchanged() { + let original = object(vec![Group { + sequence: 3, + frames: vec![untimed(b"a")], + }]); + let mut bytes = BytesMut::new(); + write_varint(&mut bytes, 2).unwrap(); + let encoded = original.encode().unwrap(); + let mut rest = &encoded[..]; + assert_eq!(read_varint(&mut rest).unwrap(), VERSION); + bytes.extend_from_slice(rest); + assert_eq!(Object::decode(bytes.freeze(), false).unwrap(), original); + } + #[test] fn payload_must_be_contiguous() { let mut good = object(vec![Group { diff --git a/rs/moq-archive/src/store.rs b/rs/moq-archive/src/store.rs index ef9c895c3a..330c0e93a6 100644 --- a/rs/moq-archive/src/store.rs +++ b/rs/moq-archive/src/store.rs @@ -146,6 +146,9 @@ impl Store { } /// Create `.info`, or accept an existing object with the same parsed properties. + /// + /// An existing recording in an older format is refused with [`Error::Version`]: a reader + /// still reads it, but nothing appends to it. pub async fn put_info(&self, track: &str, info: &Info) -> Result { let key = Key::info(track)?; let path = self.path(&key)?; @@ -155,6 +158,9 @@ impl Store { Create::Exists => { let existing = self.get_bytes(&path).await?; let parsed = Info::decode(&existing)?; + if parsed.version != info.version { + return Err(Error::Version(parsed.version)); + } if parsed.priority != info.priority { return Err(Error::Priority { existing: parsed.priority, @@ -417,7 +423,7 @@ mod tests { .inner() .put( &path, - br#"{ "timescale": 1000, "priority": 129, "version": 2 }"#.as_ref().into(), + br#"{ "timescale": 1000, "priority": 1, "version": 3 }"#.as_ref().into(), ) .await .unwrap(); @@ -428,7 +434,7 @@ mod tests { .bytes() .await .unwrap(); - assert_eq!(&kept[..], br#"{ "timescale": 1000, "priority": 129, "version": 2 }"#); + assert_eq!(&kept[..], br#"{ "timescale": 1000, "priority": 1, "version": 3 }"#); } #[tokio::test] @@ -441,8 +447,11 @@ mod tests { &br#"{"version":1,"priority":0,"timescale":1000}"#[..], (|err| matches!(err, Error::Version(1))) as fn(&Error) -> bool, ), + ("v2", br#"{"version":2,"priority":128,"timescale":1000}"#, |err| { + matches!(err, Error::Version(2)) + }), ("junk", b"not json", |err| matches!(err, Error::Json(_))), - ("zero", br#"{"version":2,"priority":0,"timescale":0}"#, |err| { + ("zero", br#"{"version":3,"priority":0,"timescale":0}"#, |err| { matches!(err, Error::Timescale(0)) }), ] { diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 69e55917ed..61f4af962a 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Priorities are signed +### Priorities are signed (#6142) `moq_track_info.priority` and `moq_subscription.priority` are `int8_t`, and `moq_group_request_priority` writes an `int8_t`, which changes the ABI. Higher is first and 0 is the unset midpoint, so a zeroed `moq_track_info` now uses the default priority rather than the least urgent one. diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 5df7924405..634a2caff2 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,7 +5,7 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased -### Priorities are signed +### Priorities are signed (#6142) `MoqTrackInfo.priority`, `MoqSubscription.priority`, `MoqFetchGroupOptions.priority`, and `MoqGroupRequest.priority()` are `i8`, higher first, and 0 is the unset midpoint. `MoqTrackInfo.priority` defaulted to 127 and now defaults to 0. diff --git a/rs/moq-ffi/src/transport/adapter.rs b/rs/moq-ffi/src/transport/adapter.rs index 8028b140ed..7b6a08e2e1 100644 --- a/rs/moq-ffi/src/transport/adapter.rs +++ b/rs/moq-ffi/src/transport/adapter.rs @@ -85,10 +85,7 @@ impl + Clone + 'st .map_err(Error) .map(|res| res.map(|(send, recv)| (SendStream(send).scheduled(options), RecvStream(recv)))) } - fn send_domain(&mut self) -> Option { - None - } - fn send_group(&mut self, _: Option<&poll::Ungrouped>, _: i64) -> Option { + fn send_group(&mut self, _: i64) -> Option { None } /// The backend cannot rank a datagram against a stream, so `options` are ignored, and a diff --git a/rs/moq-mux/MIGRATE.md b/rs/moq-mux/MIGRATE.md index 641829f9e7..367cff4cfd 100644 --- a/rs/moq-mux/MIGRATE.md +++ b/rs/moq-mux/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Priorities are signed +### Priorities are signed (#6142) `track_info` on the catalog producer and tracks takes an `i8` priority, higher first, with 0 unset; pass `hang::catalog::PRIORITY` as before. diff --git a/rs/moq-mux/src/container/test_util.rs b/rs/moq-mux/src/container/test_util.rs index fd612a2083..b69610165d 100644 --- a/rs/moq-mux/src/container/test_util.rs +++ b/rs/moq-mux/src/container/test_util.rs @@ -346,16 +346,8 @@ impl moq_net::transport::poll::Session for Gated { self.inner.poll_open_bi(cx, options) } - fn send_domain(&mut self) -> Option { - self.inner.send_domain() - } - - fn send_group( - &mut self, - domain: Option<&moq_net::transport::poll::Ungrouped>, - priority: i64, - ) -> Option { - self.inner.send_group(domain, priority) + fn send_group(&mut self, priority: i64) -> Option { + self.inner.send_group(priority) } fn send_datagram( diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 5f33c64115..4e84635fdc 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Priorities are signed, with 0 unset +### Priorities are signed, with 0 unset (#6142) `track::Info::priority`, `track::Subscription::priority`, `group::Fetch::priority`, and `group::Request::priority()` are `i8`, higher first, and 0 is the unset midpoint. The track's default was 127 and the subscription's 0, the least urgent; both are 0 now. @@ -167,16 +167,14 @@ Hold one of those, such as `track.subscribe(None)` or `track.query()` to only le ### Custom transports declare send groups (#5711) A `transport::poll::SendStream` declares `type Group` (`Ungrouped` when the transport has none) and implements `set_group`, and `set_priority` takes an `i64`. -A `transport::poll::Session` implements `send_domain` and `send_group`, returning `None` without groups. -A send group opens in a send domain, which shares bytes equally with the session's other domains, or in the session's default domain for `None`. +A `transport::poll::Session` implements `send_group`, returning `None` without groups. ```rust type Group = Ungrouped; fn set_priority(&mut self, priority: i64) { ... } fn set_group(&mut self, group: &Ungrouped) { match *group {} } // and on the Session -fn send_domain(&mut self) -> Option { None } -fn send_group(&mut self, _domain: Option<&Ungrouped>, _priority: i64) -> Option { None } +fn send_group(&mut self, _priority: i64) -> Option { None } ``` ### Superseded groups age by wall clock too (#5548) diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index a431d231d1..fa06045ed8 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -19,7 +19,6 @@ pub struct Client { cost: Option, peer_hop: Option, limits: crate::session::Limits, - schedule: crate::session::Schedule, extensions: setup::Extensions, } @@ -67,13 +66,6 @@ impl Client { self } - /// Share bandwidth between the broadcasts this session serves as `schedule` says. - /// Defaults to [`Schedule::Strict`](crate::session::Schedule::Strict). - pub fn with_schedule(mut self, schedule: crate::session::Schedule) -> Self { - self.schedule = schedule; - self - } - /// Restrict which protocol versions to offer, in preference order. /// Defaults to every version this crate supports. pub fn with_versions(mut self, versions: Versions) -> Self { @@ -219,7 +211,6 @@ impl Client { runtime: runtime.clone(), client: true, limits: self.limits, - schedule: self.schedule, session: session.clone(), setup_stream: None, publish, @@ -437,7 +428,6 @@ impl Client { runtime: runtime.clone(), client: true, limits: self.limits, - schedule: self.schedule, session: session.clone(), setup_stream: Some(stream), publish: publish.clone(), @@ -646,15 +636,7 @@ mod tests { Poll::Pending } - fn send_domain(&mut self) -> Option { - None - } - - fn send_group( - &mut self, - _: Option<&crate::transport::poll::Ungrouped>, - _: i64, - ) -> Option { + fn send_group(&mut self, _: i64) -> Option { None } @@ -1058,15 +1040,7 @@ mod tests { }) } - fn send_domain(&mut self) -> Option { - None - } - - fn send_group( - &mut self, - _: Option<&crate::transport::poll::Ungrouped>, - _: i64, - ) -> Option { + fn send_group(&mut self, _: i64) -> Option { None } diff --git a/rs/moq-net/src/coding/writer.rs b/rs/moq-net/src/coding/writer.rs index 07895ba04d..31a7f5d930 100644 --- a/rs/moq-net/src/coding/writer.rs +++ b/rs/moq-net/src/coding/writer.rs @@ -219,11 +219,6 @@ impl Writer { self.stream.as_mut().unwrap().set_priority(priority); } - /// Move the stream into `group`, which belongs to the stream's session. - pub fn set_group(&mut self, group: &S::Group) { - self.stream.as_mut().unwrap().set_group(group); - } - /// Cast the writer to a different version, used during version negotiation. pub fn with_version(mut self, version: O) -> Writer { Writer { diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index 9216fe2e93..45fc6ddd16 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -1332,16 +1332,8 @@ impl crate::transport::poll::Session for Con .map(|r| r.map(AdapterRecv::Real).map_err(|_| crate::Error::Closed)) } - fn send_domain(&mut self) -> Option> { - self.inner.send_domain() - } - - fn send_group( - &mut self, - domain: Option<&crate::transport::poll::DomainOf>, - priority: i64, - ) -> Option> { - self.inner.send_group(domain, priority) + fn send_group(&mut self, priority: i64) -> Option<::Group> { + self.inner.send_group(priority) } fn send_datagram( diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 4bf2b14935..cbe3453bc7 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2325,10 +2325,7 @@ where // resolves, so the publisher's priority is the unset default. let ranking = Ranking { queue: self.priority.clone(), - group: self - .session - .clone() - .send_group(None, crate::priority::group(priority, 0)), + group: self.session.clone().send_group(crate::priority::group(priority, 0)), request_id: msg.request_id, priority: kio::Producer::new(priority).consume(), publisher: 0, @@ -4178,7 +4175,7 @@ impl TrackServe { let mut session = session; let ranking = Ranking { queue: PriorityQueue::default(), - group: session.send_group(None, crate::priority::group(track.subscription().priority, publisher)), + group: session.send_group(crate::priority::group(track.subscription().priority, publisher)), request_id, priority: priority.consume(), publisher, diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index c35e7efe20..c7999766c7 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -103,8 +103,6 @@ pub mod path; mod priority; #[cfg(feature = "async")] mod recv; -#[cfg(feature = "async")] -mod scheduler; pub mod setup; #[cfg(feature = "async")] mod tail; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 4dabcebf84..ebf71d1137 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -19,9 +19,8 @@ use crate::{ coding::{Decode, DecodeError, Decoder, Encode, Stream, Writer}, lite, priority::{Priority, PriorityHandle, PriorityQueue}, - scheduler::{Domain, Scheduler}, track::Order, - transport::poll::{DomainOf, GroupOf, SendGroup as _, SendOptions}, + transport::poll::{GroupOf, SendGroup as _, SendOptions}, }; use super::Version; @@ -54,8 +53,6 @@ pub(super) struct PublisherConfig { /// Subscriptions the peer may hold at once (`session::Limits::subscriptions`). pub subscriptions: crate::session::Slots, pub fetches: crate::session::Slots, - /// How the session shares bandwidth between the broadcasts it serves. - pub schedule: crate::session::Schedule, } /// Context shared by every control-stream child. @@ -78,7 +75,6 @@ struct Shared { // SETUP, so its declared id never changes for the session. serving: std::sync::OnceLock, priority: PriorityQueue, - scheduler: Scheduler>, version: Version, goaway: crate::goaway::Protocol, auth: crate::auth::Handle, @@ -190,17 +186,8 @@ impl Publisher { // origin we're consuming so it matches the local relay identity // across every session, required for cross-session loop detection. let self_origin = config.origin.hop(); - let mut accept = config.session.clone(); + let accept = config.session.clone(); let enforce = (config.client && config.version.has_auth()).then(Enforce::default); - // Strict rather than refused: a peer that reaches us over a transport without send - // domains still needs serving, just in one order. - let schedule = match config.schedule { - crate::session::Schedule::Broadcast if accept.send_domain().is_none() => { - tracing::debug!("the transport has no send domains, so the session schedules strictly"); - crate::session::Schedule::Strict - } - schedule => schedule, - }; Self { enforce, shared: Arc::new(Shared { @@ -213,7 +200,6 @@ impl Publisher { peer_hop: config.peer_hop, serving: std::sync::OnceLock::new(), priority: Default::default(), - scheduler: Scheduler::new(schedule), version: config.version, goaway: config.goaway, auth: config.auth, @@ -314,7 +300,7 @@ enum ControlState { }, Announce(AnnounceServe), Subscribe(RequestServe>), - Fetch(RequestServe>), + Fetch(RequestServe), TrackInfo(RequestServe), Tracks(RequestServe), Probe(ProbeServe), @@ -2093,8 +2079,7 @@ impl Request for SubscribeServe { msg.epoch.as_ref() } - fn start(shared: &Shared, mut msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { - msg.priority = shared.scheduler.subscriber(msg.priority); + fn start(shared: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { let subscription = crate::track::Subscription { priority: msg.priority, max_delay: serving_max_delay(shared.version, msg.max_delay), @@ -2180,20 +2165,14 @@ impl Request for SubscribeServe { let mut session = shared.session.clone(); let publisher = track.info().priority; - let domain = shared.scheduler.domain(&msg.broadcast, || session.send_domain()); - let group = session.send_group( - domain.as_deref().map(|domain| domain.get()), - crate::priority::group(msg.priority, publisher), - ); + let group = session.send_group(crate::priority::group(msg.priority, publisher)); let sub = Subscription { session, id: msg.id, track_name: Arc::from(track.name()), priority: shared.priority.clone(), - scheduler: shared.scheduler.clone(), publisher, group, - _domain: domain, steer: steer_tx.consume(), steer_seen: steer, version: shared.version, @@ -2246,7 +2225,7 @@ impl Request for SubscribeServe { // A state machine's enum is its storage: one transient instance per stream, so the // big variant is the working state, not padding held in bulk. #[allow(clippy::large_enum_variant)] -enum FetchServe { +enum FetchServe { /// Waiting for the fetched group. Fetch { msg: lite::Fetch<'static>, @@ -2265,17 +2244,13 @@ enum FetchServe { batch_pos: usize, /// The `ietf` extension's object records, when negotiated. records: Option, - /// The response's send group in its broadcast's domain, on a session scheduled per - /// broadcast, so a fetch shares its broadcast's bytes rather than going ahead of - /// every broadcast's. `None` leaves it with the ungrouped streams. - _group: Option<(GroupOf, Domain>)>, }, } /// A batch of frames with their relay metadata, which the `ietf` extension forwards. type Objects = arrayvec::ArrayVec; -impl Request for FetchServe { +impl Request for FetchServe { const KIND: &'static str = "fetch"; const FETCH: bool = true; // The response carries no header, so a run cut short would read as whole. @@ -2293,8 +2268,7 @@ impl Request for FetchServe { msg.epoch.as_ref() } - fn start(shared: &Shared, mut msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { - msg.priority = shared.scheduler.subscriber(msg.priority); + fn start(_: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { let fetching = broadcast .track(&msg.track)? .fetch_group( @@ -2343,17 +2317,6 @@ impl Request for FetchServe { let capped = msg.end_frame.is_some(); let records = lite::ietf::Records::start(writer, ietf, &group, capped, timescale, shared.version)?; - let mut session = shared.session.clone(); - let placed = shared - .scheduler - .domain(&msg.broadcast, || session.send_domain()) - .and_then(|domain| { - let priority = crate::priority::group(msg.priority, group.track_priority()); - let group = session.send_group(Some(domain.get()), priority)?; - writer.set_group(&group); - Some((group, domain)) - }); - *self = Self::Serve { group, timescale, @@ -2363,7 +2326,6 @@ impl Request for FetchServe { batch: Box::default(), batch_pos: 0, records, - _group: placed, }; } Self::Serve { @@ -2375,7 +2337,6 @@ impl Request for FetchServe { batch, batch_pos, records, - .. } => { let mut cx = waiter.context(); loop { @@ -3626,15 +3587,11 @@ struct Subscription { id: u64, track_name: Arc, priority: PriorityQueue, - /// Maps the subscriber's priority updates onto the session's schedule. - scheduler: Scheduler>, /// The publisher's priority for the track, which breaks subscriber priority ties. publisher: i8, /// The subscription's send group, ranking it against the session's others, or `None` /// on a transport without groups, where `priority` ranks every group stream instead. group: Option>, - /// The broadcast's send domain, on a session scheduled per broadcast. - _domain: Option>>, steer: kio::Consumer, /// Last steer observed by this clone, so a change only fires once. steer_seen: Steer, @@ -3856,16 +3813,15 @@ impl TrackRun { { self.track.join(); } - let priority = self.ctx.scheduler.subscriber(upd.priority); if let Ok(mut value) = self.steer_tx.write() { *value = Steer { - priority, + priority: upd.priority, order: upd.order, }; } // One move for the whole subscription; its streams keep their order within it. if let Some(group) = &mut self.ctx.group { - group.set_priority(crate::priority::group(priority, self.ctx.publisher)); + group.set_priority(crate::priority::group(upd.priority, self.ctx.publisher)); } // Feed the full update into the model subscriber so the producer's // aggregate reflects it (and a relay re-forwards it upstream). @@ -3874,7 +3830,7 @@ impl TrackRun { let floored = requested.is_some_and(|group| group > 0); let bounds = Bounds::from(&upd); let _ = self.track.update(crate::track::Subscription { - priority, + priority: upd.priority, max_delay: serving_max_delay(self.ctx.version, upd.max_delay), order: upd.order, ..bounds.positions() @@ -4432,7 +4388,6 @@ impl GroupServe { batch, batch_pos, records, - .. } => { let mut cx = waiter.context(); @@ -4769,10 +4724,8 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), - scheduler: Scheduler::new(Default::default()), publisher: 0, group: None, - _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -4818,10 +4771,8 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), - scheduler: Scheduler::new(Default::default()), publisher: 0, group: None, - _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -4871,10 +4822,8 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), - scheduler: Scheduler::new(Default::default()), publisher: 0, group: None, - _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -4924,10 +4873,8 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), - scheduler: Scheduler::new(Default::default()), publisher: 0, group: None, - _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -4995,10 +4942,8 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), - scheduler: Scheduler::new(Default::default()), publisher: 0, group: None, - _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -5075,10 +5020,8 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), - scheduler: Scheduler::new(Default::default()), publisher: 0, group: None, - _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -5127,10 +5070,8 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), - scheduler: Scheduler::new(Default::default()), publisher: 0, group: None, - _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -5178,10 +5119,8 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), - scheduler: Scheduler::new(Default::default()), publisher: 0, group: None, - _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite06, @@ -5241,10 +5180,8 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), - scheduler: Scheduler::new(Default::default()), publisher: 0, group: None, - _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version, @@ -5322,7 +5259,7 @@ mod serve_group_test { let mut session = SinkSession::new(Log::default()).with_groups().with_unacked_fin(); let log = session.log.clone(); let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); - run.ctx.group = crate::transport::poll::Session::send_group(&mut session, None, 3); + run.ctx.group = crate::transport::poll::Session::send_group(&mut session, 3); write_group(&mut track, 0, 0); write_group(&mut track, 1, 1); @@ -5379,7 +5316,7 @@ mod serve_group_test { .with_unacked_fin(); let log = session.log.clone(); let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); - run.ctx.group = crate::transport::poll::Session::send_group(&mut session, None, 3); + run.ctx.group = crate::transport::poll::Session::send_group(&mut session, 3); write_group(&mut track, 0, 0); let sequence = track @@ -5408,7 +5345,7 @@ mod serve_group_test { let mut session = SinkSession::new(Log::default()).with_groups().with_datagrams(1200); let log = session.log.clone(); let (mut run, mut writer, _) = lite_run(Version::Lite06, session.clone(), subscriber); - run.ctx.group = crate::transport::poll::Session::send_group(&mut session, None, 3); + run.ctx.group = crate::transport::poll::Session::send_group(&mut session, 3); let first = track .append_datagram(Timestamp::from_millis(1).unwrap(), b"a".as_slice()) @@ -6379,10 +6316,8 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), - scheduler: Scheduler::new(Default::default()), publisher: 0, group: None, - _domain: None, steer: steer.consume(), steer_seen: Steer::default(), version: Version::Lite07, @@ -6683,7 +6618,6 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), - schedule: Default::default(), estimate: kio::Producer::default().weak(), }); @@ -6855,7 +6789,6 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), - schedule: Default::default(), estimate: estimate.weak(), }); ProbeServe::new(publisher.shared.clone(), publisher.runtime.clone(), stream) @@ -7156,7 +7089,6 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), - schedule: Default::default(), estimate: kio::Producer::default().weak(), }); @@ -7253,7 +7185,7 @@ mod tests { start_frame: 0, end_frame: None, }; - leave_request::>(version, &fetch, wait, close).await; + leave_request::(version, &fetch, wait, close).await; } } } @@ -7287,7 +7219,6 @@ mod tests { peer_hop: None, subscriptions: Default::default(), fetches: Default::default(), - schedule: Default::default(), auth: crate::auth::Handle::new(false), peer_grant: Default::default(), client: false, @@ -7380,7 +7311,6 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), - schedule: Default::default(), estimate: kio::Producer::default().weak(), }); @@ -7497,7 +7427,6 @@ mod tests { peer_hop: None, subscriptions: Default::default(), fetches: Default::default(), - schedule: Default::default(), auth: crate::auth::Handle::new(false), peer_grant: Default::default(), client: false, @@ -7659,7 +7588,6 @@ mod tests { peer_hop: None, subscriptions: crate::session::Slots::new(0), fetches: Default::default(), - schedule: Default::default(), auth: crate::auth::Handle::new(false), peer_grant: Default::default(), client: false, @@ -7704,7 +7632,6 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), - schedule: Default::default(), estimate: kio::Producer::default().weak(), }); let mut script = Vec::new(); @@ -7730,7 +7657,7 @@ mod tests { writer: Writer::new(send, VERSION), reader: crate::coding::Reader::new(recv, VERSION), }; - let mut serve = RequestServe::<_, FetchServe<_>>::new(publisher.shared.clone(), stream); + let mut serve = RequestServe::<_, FetchServe>::new(publisher.shared.clone(), stream); assert!(!drive(&mut serve).await, "the open group holds the response"); // The track runs a full second past the group's successor. @@ -7772,7 +7699,6 @@ mod tests { client: false, subscriptions: crate::session::Slots::new(0), fetches: crate::session::Slots::new(2), - schedule: Default::default(), estimate: kio::Producer::default().weak(), }); let mut script = Vec::new(); @@ -7801,7 +7727,7 @@ mod tests { writer: Writer::new(send, VERSION), reader: crate::coding::Reader::new(recv, VERSION), }; - let mut request = RequestServe::<_, FetchServe<_>>::new(publisher.shared.clone(), stream); + let mut request = RequestServe::<_, FetchServe>::new(publisher.shared.clone(), stream); if count < 2 { assert!(!drive(&mut request).await); held.push(request); @@ -8139,23 +8065,14 @@ mod tests { } } - /// A publisher on a session with send domains and groups, scheduled as `schedule`. - fn grouped_publisher(origin: origin::Consumer, schedule: crate::session::Schedule) -> Publisher { - scheduled_publisher(origin, schedule, ScriptedSession::new(Vec::new()).with_groups()) - } - - /// A publisher on `session`, scheduled as `schedule`. - fn scheduled_publisher( - origin: origin::Consumer, - schedule: crate::session::Schedule, - session: ScriptedSession, - ) -> Publisher { + /// A publisher on a session with send groups. + fn grouped_publisher(origin: origin::Consumer) -> Publisher { let peer_setup = crate::lite::PeerSetup::new(false, crate::transport::Binding::InBand); peer_setup.set(crate::lite::Setup::default()); let (_, goaway) = crate::goaway::Handle::new(true); Publisher::new(PublisherConfig { runtime: crate::time::sim(), - session, + session: ScriptedSession::new(Vec::new()).with_groups(), origin, version: Version::Lite07, peer_setup, @@ -8166,40 +8083,10 @@ mod tests { client: false, subscriptions: Default::default(), fetches: Default::default(), - schedule, estimate: kio::Producer::default().weak(), }) } - /// Serve a request stream whose peer sends `request`, returning that peer. - async fn serve_request>( - publisher: &Publisher, - request: &impl Encode, - ) -> (ScriptedSession, RequestServe) { - let version = Version::Lite07; - let mut script = Vec::new(); - request - .encode(&mut crate::coding::Encoder::new(&mut script, version.into()), version) - .unwrap(); - let mut session = ScriptedSession::new(script); - let (send, recv) = futures::future::poll_fn(|cx| { - ::poll_open_bi(&mut session, cx, Default::default()) - }) - .await - .unwrap(); - let stream = Stream:: { - writer: Writer::new(send, version), - reader: crate::coding::Reader::new(recv, version), - }; - (session, RequestServe::new(publisher.shared.clone(), stream)) - } - - type Subscribed = ( - ScriptedSession, - RequestServe>, - track::Producer, - ); - /// Subscribe to `broadcast`'s `track` at the subscriber `priority`, resolving the track /// with `info`, until the subscription runs. async fn subscribe_at( @@ -8209,7 +8096,11 @@ mod tests { track: &'static str, priority: i8, info: track::Info, - ) -> Subscribed { + ) -> ( + RequestServe>, + track::Producer, + ) { + let version = Version::Lite07; let msg = lite::Subscribe { order: crate::track::Order::Descending, epoch: None, @@ -8223,7 +8114,20 @@ mod tests { start_frame: 0, end_frame: None, }; - let (session, mut serve) = serve_request::>(publisher, &msg).await; + let mut script = Vec::new(); + msg.encode(&mut crate::coding::Encoder::new(&mut script, version.into()), version) + .unwrap(); + let mut session = ScriptedSession::new(script); + let (send, recv) = futures::future::poll_fn(|cx| { + ::poll_open_bi(&mut session, cx, Default::default()) + }) + .await + .unwrap(); + let stream = Stream:: { + writer: Writer::new(send, version), + reader: crate::coding::Reader::new(recv, version), + }; + let mut serve = RequestServe::<_, SubscribeServe<_>>::new(publisher.shared.clone(), stream); assert!(!drive(&mut serve).await); let track = dynamic .requested_track() @@ -8236,167 +8140,29 @@ mod tests { matches!(serve.state, RequestState::Serve(SubscribeServe::Run(_))), "the subscription is not running" ); - (session, serve, track) + (serve, track) } - /// Two announced broadcasts, `a` and `b`, with their track requests. - fn two_broadcasts() -> (origin::Producer, [(broadcast::Producer, broadcast::Dynamic); 2]) { - let origin = crate::origin::Config::new(Hop::new(1).unwrap()).produce(); - let broadcasts = ["a", "b"].map(|name| { - let broadcast = origin.create_broadcast(name).unwrap(); - let dynamic = broadcast.dynamic(); - broadcast.announce(Default::default()).unwrap(); - (broadcast, dynamic) - }); - (origin, broadcasts) - } - - /// Scheduled per broadcast, a broadcast's subscriptions share one send domain, so the - /// transport splits bytes evenly between broadcasts however many tracks or subscribers - /// each has. Inside it the publisher's track priority alone orders the groups: the - /// peer's subscriber priority, at either extreme or updated, moves nothing, and is not - /// fed upstream. + /// A subscription's send group ranks by the peer's subscriber priority first, and the + /// publisher's track priority breaks a tie between two at one subscriber priority. #[moq_clock::test] - async fn per_broadcast_schedule_ranks_by_the_publisher_alone() { - let (origin, [(_a, mut a), (_b, mut b)]) = two_broadcasts(); - let publisher = grouped_publisher(origin.consume(), crate::session::Schedule::Broadcast); - let log = publisher.shared.session.log.clone(); - let video = || track::Info::timed().with_priority(60); - let audio = || track::Info::timed().with_priority(80); - - let mut a_video = subscribe_at(&publisher, &mut a, "a", "video", i8::MAX, video()).await; - let _a_audio = subscribe_at(&publisher, &mut a, "a", "audio", i8::MIN, audio()).await; - let _b_video = subscribe_at(&publisher, &mut b, "b", "video", 0, video()).await; - assert_eq!(log.groups(), [(0, 60), (1, 80), (2, 60)]); - let domains = log.domains(); - assert!( - domains[0].is_some() && domains[1] == domains[0] && domains[2].is_some() && domains[2] != domains[0], - "not one domain per broadcast: {domains:?}" - ); - - let update = lite::SubscribeUpdate { - wanted_group: None, - order: crate::track::Order::Descending, - priority: i8::MIN, - max_delay: Duration::ZERO, - start_group: None, - end_group: None, - start_frame: 0, - end_frame: None, - }; - a_video.0.push(&update.encode_bytes(Version::Lite07).unwrap()); - assert!(!drive(&mut a_video.1).await, "the update ended the subscription"); - assert!( - log.groups() - .iter() - .all(|&(group, priority)| group != 0 || priority == 60), - "the update moved the group: {:?}", - log.groups() - ); - let RequestState::Serve(SubscribeServe::Run(run)) = &a_video.1.state else { - panic!("the subscription is not running"); - }; - assert_eq!(run.steer_tx.read().priority, 0, "the subscriber priority was kept"); - } - - /// The default schedule keeps every subscription in the session's default domain, - /// ranked by the peer's subscriber priority first and the publisher's after. - #[moq_clock::test] - async fn strict_schedule_ranks_by_the_subscriber_first() { - let (origin, [(_a, mut a), (_b, mut b)]) = two_broadcasts(); - let publisher = grouped_publisher(origin.consume(), crate::session::Schedule::Strict); - let log = publisher.shared.session.log.clone(); - - let _a = subscribe_at( - &publisher, - &mut a, - "a", - "video", - i8::MAX, - track::Info::timed().with_priority(60), - ) - .await; - let _b = subscribe_at( - &publisher, - &mut b, - "b", - "audio", - i8::MIN, - track::Info::timed().with_priority(80), - ) - .await; - assert_eq!( - log.groups(), - [ - (0, crate::priority::group(i8::MAX, 60)), - (1, crate::priority::group(i8::MIN, 80)) - ] - ); - assert_eq!(log.domains(), [None, None]); - } - - /// Asked to schedule per broadcast over a transport without send domains, the publisher - /// serves strictly rather than refusing the peer, so the subscriber priority still ranks. - #[moq_clock::test] - async fn per_broadcast_schedule_without_send_domains_is_strict() { - let (origin, [(_a, mut a), _]) = two_broadcasts(); - let publisher = scheduled_publisher( - origin.consume(), - crate::session::Schedule::Broadcast, - ScriptedSession::new(Vec::new()), - ); - let (_session, serve, _track) = - subscribe_at(&publisher, &mut a, "a", "video", i8::MAX, track::Info::timed()).await; - let RequestState::Serve(SubscribeServe::Run(run)) = &serve.state else { - panic!("the subscription is not running"); - }; - assert_eq!( - run.steer_tx.read().priority, - i8::MAX, - "the subscriber priority was dropped" - ); - } - - /// Scheduled per broadcast, a FETCH response joins its broadcast's domain at the - /// track's publisher priority, so a backfill shares its broadcast's bytes rather than - /// going ahead of every broadcast's. - #[moq_clock::test] - async fn per_broadcast_schedule_places_a_fetch_in_its_broadcast() { - let (origin, [(_a, mut a), _]) = two_broadcasts(); - let publisher = grouped_publisher(origin.consume(), crate::session::Schedule::Broadcast); + async fn send_group_ranks_by_the_subscriber_then_the_publisher() { + let origin = crate::origin::Config::new(Hop::new(1).unwrap()).produce(); + let broadcast = origin.create_broadcast("a").unwrap(); + let mut dynamic = broadcast.dynamic(); + broadcast.announce(Default::default()).unwrap(); + let publisher = grouped_publisher(origin.consume()); let log = publisher.shared.session.log.clone(); - let (_session, _serve, track) = subscribe_at( - &publisher, - &mut a, - "a", - "video", - 0, - track::Info::timed().with_priority(60), - ) - .await; - let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); - group.write_frame(crate::Timestamp::ZERO, b"x".as_slice()).unwrap(); - group.finish().unwrap(); + let video = track::Info::timed().with_priority(60); + let audio = track::Info::timed().with_priority(80); + let _video = subscribe_at(&publisher, &mut dynamic, "a", "video", 0, video).await; + let _audio = subscribe_at(&publisher, &mut dynamic, "a", "audio", 0, audio).await; + let _urgent = subscribe_at(&publisher, &mut dynamic, "a", "chat", 1, track::Info::timed()).await; - let fetch = lite::Fetch { - epoch: None, - broadcast: crate::Path::new("a"), - track: "video".into(), - priority: i8::MAX, - max_delay: None, - group: 0, - start_frame: 0, - end_frame: None, - }; - let (session, mut serve) = serve_request::>(&publisher, &fetch).await; - drive(&mut serve).await; - assert_eq!(log.groups(), [(0, 60), (1, 60)]); - let domains = log.domains(); - assert!( - domains[0].is_some() && domains[1] == domains[0], - "the fetch left its broadcast's domain: {domains:?}" - ); - assert_eq!(session.log.joins(), [(0, 1)], "the response never joined its group"); + let groups = log.groups(); + assert_eq!(groups.len(), 3); + assert!(groups[1].1 > groups[0].1, "the publisher's priority breaks the tie"); + assert!(groups[2].1 > groups[1].1, "the subscriber's priority dominates"); } } diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index b646cee0e2..3e671e2099 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -189,8 +189,6 @@ pub struct Config { pub auth: crate::auth::Handle, /// What the peer may make this session hold. pub limits: crate::session::Limits, - /// How the session shares bandwidth between the broadcasts it serves. - pub schedule: crate::session::Schedule, } /// Start a lite session. @@ -213,7 +211,6 @@ where peer_setup, auth, limits, - schedule, } = config; let recv_bw = bandwidth::Producer::new(); @@ -311,7 +308,6 @@ where client, subscriptions, fetches: crate::session::Slots::new(limits.fetches), - schedule, }); let mut subscriber = Subscriber::new(SubscriberConfig { runtime: runtime.clone(), @@ -918,7 +914,6 @@ mod tests { runtime: crate::time::sim(), client: true, limits: Default::default(), - schedule: Default::default(), session: transport, setup_stream: None, publish: None, @@ -951,7 +946,6 @@ mod tests { runtime: crate::time::sim(), client: false, limits: Default::default(), - schedule: Default::default(), session: transport, setup_stream: None, publish: None, @@ -1038,7 +1032,6 @@ mod tests { runtime: crate::time::sim(), client: true, limits: Default::default(), - schedule: Default::default(), session: transport, setup_stream: None, publish: None, @@ -1117,7 +1110,6 @@ mod tests { client: false, auth: crate::auth::Handle::new(version.has_auth()), limits: Default::default(), - schedule: Default::default(), }) .unwrap(); let _ = started.driver.poll(&kio::Waiter::noop()); @@ -1172,7 +1164,6 @@ mod tests { client: false, auth: crate::auth::Handle::new(version.has_auth()), limits: Default::default(), - schedule: Default::default(), }) .unwrap(); let _ = started.driver.poll(&kio::Waiter::noop()); @@ -1205,7 +1196,6 @@ mod tests { peer_setup, auth: crate::auth::Handle::new(version.has_auth()), limits: Default::default(), - schedule: Default::default(), }) .unwrap() } diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index aea1468f9e..f23e30cc9f 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -60,10 +60,6 @@ pub struct Log { /// Hands each send group its id. group_ids: Arc, groups: Arc>>, - /// Hands each send domain its id. - domain_ids: Arc, - /// The domain each send group opened in, by group id. - domains: Arc>>>, joins: Arc>>, orders: Arc>>, } @@ -123,12 +119,6 @@ impl Log { self.groups.lock().unwrap().clone() } - /// The send domain each send group opened in, indexed by group id: `None` for the - /// session's default domain. - pub fn domains(&self) -> Vec> { - self.domains.lock().unwrap().clone() - } - /// Every send group a stream joined, as (stream, group) in call order. pub fn joins(&self) -> Vec<(usize, usize)> { self.joins.lock().unwrap().clone() @@ -215,36 +205,14 @@ pub struct SinkGroup { } impl SinkGroup { - fn open(log: Log, domain: Option, priority: i64) -> Self { + fn open(log: Log, priority: i64) -> Self { let id = log.group_ids.fetch_add(1, Ordering::Relaxed); log.groups.lock().unwrap().push((id, priority)); - let mut domains = log.domains.lock().unwrap(); - if domains.len() <= id { - domains.resize(id + 1, None); - } - domains[id] = domain; - drop(domains); Self { id, log } } } -/// A send domain of a [`SinkSession::with_groups`], identified by its opening order. -#[derive(Clone)] -pub struct SinkDomain { - id: usize, -} - -impl SinkDomain { - fn open(log: &Log) -> Self { - Self { - id: log.domain_ids.fetch_add(1, Ordering::Relaxed), - } - } -} - impl poll::SendGroup for SinkGroup { - type Domain = SinkDomain; - fn set_priority(&mut self, priority: i64) { self.log.groups.lock().unwrap().push((self.id, priority)); } @@ -538,11 +506,7 @@ impl poll::Session for DeadStreamSession { Poll::Ready(Ok(SinkSend::new(self.log.clone()).opened(options))) } - fn send_domain(&mut self) -> Option { - None - } - - fn send_group(&mut self, _: Option<&SinkDomain>, _: i64) -> Option { + fn send_group(&mut self, _: i64) -> Option { None } @@ -763,13 +727,8 @@ impl poll::Session for SinkSession { Poll::Ready(Ok(send.opened(options))) } - fn send_domain(&mut self) -> Option { - self.groups.then(|| SinkDomain::open(&self.log)) - } - - fn send_group(&mut self, domain: Option<&SinkDomain>, priority: i64) -> Option { - self.groups - .then(|| SinkGroup::open(self.log.clone(), domain.map(|domain| domain.id), priority)) + fn send_group(&mut self, priority: i64) -> Option { + self.groups.then(|| SinkGroup::open(self.log.clone(), priority)) } fn send_datagram( @@ -955,7 +914,7 @@ impl ScriptedSession { } } - /// Open send domains and groups, as native QUIC does, recording them in the log. + /// Open send groups, as native QUIC does, recording them in the log. pub fn with_groups(mut self) -> Self { self.groups = true; self @@ -1115,13 +1074,8 @@ impl poll::Session for ScriptedSession { Poll::Ready(Ok(SinkSend::new(self.log.clone()).opened(options))) } - fn send_domain(&mut self) -> Option { - self.groups.then(|| SinkDomain::open(&self.log)) - } - - fn send_group(&mut self, domain: Option<&SinkDomain>, priority: i64) -> Option { - self.groups - .then(|| SinkGroup::open(self.log.clone(), domain.map(|domain| domain.id), priority)) + fn send_group(&mut self, priority: i64) -> Option { + self.groups.then(|| SinkGroup::open(self.log.clone(), priority)) } fn send_datagram( diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 8053801d71..8cb9f03023 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -2228,13 +2228,6 @@ impl Consumer { self.track.timescale } - /// The parent track's publisher priority, [`track::Info::priority`]. - #[cfg(feature = "async")] - // private: passthrough - pub(crate) fn track_priority(&self) -> i8 { - self.track.priority - } - /// The index of the next frame this consumer will return. /// /// Starts at 0, or at the group's first available frame once [`Self::set_frames`] has diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 07b9f166bd..aff34f9343 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -135,10 +135,8 @@ pub struct Info { pub max_age: Option, /// The publisher's priority for this track, higher first, defaulting to 0. /// - /// It breaks ties between subscriptions of equal subscriber priority. On a session - /// scheduled per broadcast (`session::Schedule::Broadcast`) - /// it replaces the subscriber's priority, ordering the broadcast's tracks among - /// themselves. Reported in TRACK_INFO (Lite05+). + /// It breaks ties between subscriptions of equal subscriber priority. Reported in + /// TRACK_INFO (Lite05+). pub priority: i8, /// The ratio of a group's bytes after its first frame to the first frame past which a /// joining subscriber asks for a new group, or `None` (the default) to never ask. diff --git a/rs/moq-net/src/scheduler.rs b/rs/moq-net/src/scheduler.rs deleted file mode 100644 index d196a6107c..0000000000 --- a/rs/moq-net/src/scheduler.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Where a session's publisher places the streams it serves, following its [`Schedule`]. - -use std::{ - collections::HashMap, - sync::{Arc, Mutex, Weak}, -}; - -use crate::{Path, session::Schedule}; - -type Registry = Mutex>>>; - -/// Ranks what a session's publisher serves: one strict order, or a send domain per broadcast. -pub(crate) struct Scheduler { - schedule: Schedule, - /// Each served broadcast's send domain, while anything of it is in flight. - domains: Arc>, -} - -impl Clone for Scheduler { - fn clone(&self) -> Self { - Self { - schedule: self.schedule, - domains: self.domains.clone(), - } - } -} - -/// A broadcast's send domain, released once the last handle is. -pub(crate) type Domain = Arc>; - -pub(crate) struct Held { - domain: D, - broadcast: String, - domains: Weak>, -} - -impl Held { - /// The transport's domain. - pub fn get(&self) -> &D { - &self.domain - } -} - -impl Drop for Held { - fn drop(&mut self) { - let Some(domains) = self.domains.upgrade() else { - return; - }; - let mut domains = domains.lock().unwrap(); - // A later stream of the broadcast may have opened a fresh domain since this one's last - // handle dropped, and that entry stays. - if domains - .get(&self.broadcast) - .is_some_and(|entry| entry.strong_count() == 0) - { - domains.remove(&self.broadcast); - } - } -} - -impl Scheduler { - pub fn new(schedule: Schedule) -> Self { - Self { - schedule, - domains: Default::default(), - } - } - - /// The subscriber priority a peer's request is served at: as asked on a strict session, - /// the unset default on one scheduled per broadcast, so it neither ranks the request's - /// streams nor reaches the publisher upstream. - pub fn subscriber(&self, requested: i8) -> i8 { - match self.schedule { - Schedule::Strict => requested, - Schedule::Broadcast => 0, - } - } - - /// The send domain for streams of `broadcast`: `None`, the session's default, on a strict - /// session, or the broadcast's own, opened with `open` unless one is already live. - /// - /// Hold the returned handle for as long as any stream of the broadcast is in flight. - pub fn domain(&self, broadcast: &Path, open: impl FnOnce() -> Option) -> Option> { - if self.schedule == Schedule::Strict { - return None; - } - - let mut domains = self.domains.lock().unwrap(); - if let Some(held) = domains.get(broadcast.as_str()).and_then(Weak::upgrade) { - return Some(held); - } - // `None` only on a transport without domains, where the publisher schedules strictly. - let held = Arc::new(Held { - domain: open()?, - broadcast: broadcast.as_str().to_owned(), - domains: Arc::downgrade(&self.domains), - }); - domains.insert(held.broadcast.clone(), Arc::downgrade(&held)); - Some(held) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn strict_uses_the_default_domain() { - let scheduler = Scheduler::::new(Schedule::Strict); - assert!(scheduler.domain(&Path::new("a"), || Some(1)).is_none()); - assert_eq!(scheduler.subscriber(9), 9); - } - - /// Streams of one broadcast share a domain while any is in flight, and the next one after - /// the last left opens a fresh domain. - #[test] - fn one_domain_per_live_broadcast() { - let scheduler = Scheduler::new(Schedule::Broadcast); - let mut opened = 0; - let mut open = || { - opened += 1; - Some(opened) - }; - let a = scheduler.domain(&Path::new("a"), &mut open).unwrap(); - let again = scheduler.domain(&Path::new("a"), &mut open).unwrap(); - let b = scheduler.domain(&Path::new("b"), &mut open).unwrap(); - assert_eq!((*a.get(), *again.get(), *b.get()), (1, 1, 2)); - assert_eq!(scheduler.subscriber(9), 0, "the peer's priority is ignored"); - - drop((a, again)); - assert_eq!(scheduler.domains.lock().unwrap().len(), 1, "a's entry left with it"); - let a = scheduler.domain(&Path::new("a"), &mut open).unwrap(); - assert_eq!(*a.get(), 3); - } -} diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index b73bf04fa1..27f5c9fc70 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -69,7 +69,6 @@ pub struct Server { stats: stats::Session, versions: Versions, limits: crate::session::Limits, - schedule: crate::session::Schedule, extensions: setup::Extensions, } @@ -115,13 +114,6 @@ impl Server { self } - /// Share bandwidth between the broadcasts each session serves as `schedule` says. - /// Defaults to [`Schedule::Strict`](crate::session::Schedule::Strict). - pub fn with_schedule(mut self, schedule: crate::session::Schedule) -> Self { - self.schedule = schedule; - self - } - /// Restrict which protocol versions to accept, in preference order. /// Defaults to every version this crate supports. pub fn with_versions(mut self, versions: Versions) -> Self { @@ -186,7 +178,6 @@ impl Server { runtime: runtime.clone(), client: false, limits: self.limits, - schedule: self.schedule, session: session.clone(), setup_stream: None, publish, @@ -748,7 +739,6 @@ where runtime: runtime.clone(), client: false, limits: server.limits, - schedule: server.schedule, session: session.clone(), setup_stream: Some(stream), publish, @@ -913,14 +903,6 @@ where self } - /// Set how the session shares bandwidth between the broadcasts it serves, after - /// authorizing the peer and before answering SETUP. Overrides any value from the - /// [`Server`] builder. - pub fn with_schedule(mut self, schedule: crate::session::Schedule) -> Self { - self.inner_mut().server.schedule = schedule; - self - } - fn inner_mut(&mut self) -> &mut RequestInner { self.inner.as_mut().expect("request already responded") } @@ -1100,15 +1082,7 @@ mod tests { ) -> std::task::Poll> { std::task::Poll::Pending } - fn send_domain(&mut self) -> Option { - None - } - - fn send_group( - &mut self, - _: Option<&crate::transport::poll::Ungrouped>, - _: i64, - ) -> Option { + fn send_group(&mut self, _: i64) -> Option { None } diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 61aa536a5b..09a0d80bac 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -750,30 +750,6 @@ impl Limits { } } -/// How a session shares its bandwidth between the broadcasts it serves. -/// -/// Priority orders streams only within one scheduling domain. Where one party owns both -/// ends of a session, like a publisher and its ingest relay or an edge relay and a viewer, -/// that domain is the whole session. A relay-to-relay session carries every tenant's -/// broadcasts, so there each broadcast is its own domain. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[non_exhaustive] -pub enum Schedule { - /// One strict order across the session: each subscription's priority, the publisher's - /// [`track::Info::priority`](crate::track::Info::priority) breaking ties. - #[default] - Strict, - /// Each broadcast gets an equal share of bytes, however many subscriptions or fetches it - /// carries, and the publisher's [`track::Info::priority`](crate::track::Info::priority) - /// orders the streams inside it. The peer's subscriber priority is ignored, neither - /// ranking its streams nor forwarded upstream. - /// - /// Only moq-lite sessions on a transport with send domains (QUIC and WebTransport over - /// moq-quic) can share by broadcast. Any other session, over WebSocket, iroh, or - /// moq-transport, serves [`Self::Strict`]. - Broadcast, -} - /// A count of live entries shared across a session, refused past its cap. #[derive(Clone)] pub(crate) struct Slots { diff --git a/rs/moq-net/src/transport.rs b/rs/moq-net/src/transport.rs index afdc9bdb48..ddde6f5065 100644 --- a/rs/moq-net/src/transport.rs +++ b/rs/moq-net/src/transport.rs @@ -220,23 +220,16 @@ pub mod poll { options: SendOptions<'_, ::Group>, ) -> Poll, Self::Error>>; - /// Open a send domain, or `None` when the transport has none. - /// - /// Domains share the bytes left after the ungrouped streams equally, however many - /// groups each holds, and the session's default domain is one of them. The domain is - /// released once its last handle is dropped and its groups are. - fn send_domain(&mut self) -> Option>; - - /// Open a send group at `priority` in `domain`, or the session's default domain, or - /// `None` when the transport orders streams by their own priority alone. + /// Open a send group at `priority`, or `None` when the transport orders streams by + /// their own priority alone. /// /// Streams that join a group, through [`SendOptions`] or [`SendStream::set_group`], and - /// datagrams sent in it share their domain's bytes with its other groups: a higher - /// priority group preempts every lower one, and groups of equal priority get an equal - /// share of bytes however many streams each has queued. Streams outside every group go - /// before all of them. The group is released once its last handle is dropped and its - /// streams finish. - fn send_group(&mut self, domain: Option<&DomainOf>, priority: i64) -> Option>; + /// datagrams sent in it share bytes with the session's + /// other groups: a higher priority group preempts every lower one, and groups of equal + /// priority get an equal share of bytes however many streams each has queued. Streams + /// outside every group go before all of them. The group is released once its last + /// handle is dropped and its streams finish. + fn send_group(&mut self, priority: i64) -> Option<::Group>; /// Send a datagram scheduled by `options`, best-effort. /// @@ -405,7 +398,7 @@ pub mod poll { /// requires until it too becomes named machines. Implemented automatically. pub trait Boxable: Session< - SendStream: MaybeSend + SendStream>, + SendStream: MaybeSend + SendStream, RecvStream: MaybeSend, Error: MaybeSend, > + MaybeSend @@ -415,8 +408,7 @@ pub mod poll { impl Boxable for S where S: Session< - SendStream: MaybeSend - + SendStream>, + SendStream: MaybeSend + SendStream, RecvStream: MaybeSend, Error: MaybeSend, > + MaybeSend @@ -425,10 +417,8 @@ pub mod poll { } /// The send group of a session's streams. - pub type GroupOf = <::SendStream as SendStream>::Group; - - /// The send domain of a session's groups. - pub type DomainOf = as SendGroup>::Domain; + #[cfg(feature = "async")] + pub(crate) type GroupOf = <::SendStream as SendStream>::Group; /// How an outgoing stream or datagram is scheduled, like W3C WebTransport's send options. /// @@ -463,10 +453,7 @@ pub mod poll { /// /// Clones share the group. pub trait SendGroup: Clone + 'static { - /// The transport's send domain, from [`Session::send_domain`]. Clones share the domain. - type Domain: Clone + 'static; - - /// Change the group's priority: higher preempts lower within its domain. + /// Change the group's priority: higher preempts lower. fn set_priority(&mut self, priority: i64); } @@ -475,8 +462,6 @@ pub mod poll { pub enum Ungrouped {} impl SendGroup for Ungrouped { - type Domain = Ungrouped; - fn set_priority(&mut self, _: i64) { match *self {} } diff --git a/rs/moq-net/src/transport/memory.rs b/rs/moq-net/src/transport/memory.rs index 68fb43f7ef..db944fd01f 100644 --- a/rs/moq-net/src/transport/memory.rs +++ b/rs/moq-net/src/transport/memory.rs @@ -302,15 +302,7 @@ impl poll::Session for Session { Poll::Ready(opened.map(|()| (send, recv))) } - fn send_domain(&mut self) -> Option { - None - } - - fn send_group( - &mut self, - _: Option<&crate::transport::poll::Ungrouped>, - _: i64, - ) -> Option { + fn send_group(&mut self, _: i64) -> Option { None } diff --git a/rs/moq-net/tests/integration/support/mock.rs b/rs/moq-net/tests/integration/support/mock.rs index 42cf675cd8..3ee81a8a73 100644 --- a/rs/moq-net/tests/integration/support/mock.rs +++ b/rs/moq-net/tests/integration/support/mock.rs @@ -605,11 +605,7 @@ impl poll::Session for MockSession { Poll::Ready(Ok(send)) } - fn send_domain(&mut self) -> Option { - None - } - - fn send_group(&mut self, _: Option<&poll::Ungrouped>, _: i64) -> Option { + fn send_group(&mut self, _: i64) -> Option { None } diff --git a/rs/moq-quic/src/connection/mod.rs b/rs/moq-quic/src/connection/mod.rs index d088f6ec5b..8ce51c2562 100644 --- a/rs/moq-quic/src/connection/mod.rs +++ b/rs/moq-quic/src/connection/mod.rs @@ -93,9 +93,8 @@ pub use stats::{ConnectionStats, CreditStats, FrameStats, PathStats, UdpStats}; mod streams; pub(crate) use streams::StreamsState; pub use streams::{ - Chunks, ClosedStream, Credit, FinishError, ReadError, ReadableError, RecvStream, SendDomain, - SendGroup, SendGroups, SendOptions, SendStream, ShouldTransmit, StreamEvent, Streams, - WriteError, Written, + Chunks, ClosedStream, Credit, FinishError, ReadError, ReadableError, RecvStream, SendGroup, + SendGroups, SendOptions, SendStream, ShouldTransmit, StreamEvent, Streams, WriteError, Written, }; mod timer; diff --git a/rs/moq-quic/src/connection/streams/mod.rs b/rs/moq-quic/src/connection/streams/mod.rs index 5411b42b33..c3f160c362 100644 --- a/rs/moq-quic/src/connection/streams/mod.rs +++ b/rs/moq-quic/src/connection/streams/mod.rs @@ -546,15 +546,26 @@ pub struct SendGroups<'a> { } impl SendGroups<'_> { - /// Open a send group at `priority` in `domain`, or the connection's default domain + /// Open a send group at `priority` + pub fn open(&mut self, priority: i64) -> SendGroup { + SendGroup { + index: self.state.pending.open_group(priority, None), + tag: self.state.tag, + } + } + + /// Open a send group at `priority` in `domain` /// /// # Panics /// - when `domain` was opened on another connection - pub fn open(&mut self, domain: Option<&SendDomain>, priority: i64) -> SendGroup { + #[cfg(test)] + pub(crate) fn open_in(&mut self, priority: i64, domain: &SendDomain) -> SendGroup { let tag = self.state.tag; - let domain = domain.map(|domain| domain.index(tag)); SendGroup { - index: self.state.pending.open_group(priority, domain), + index: self + .state + .pending + .open_group(priority, Some(domain.index(tag))), tag, } } @@ -588,9 +599,8 @@ impl SendGroups<'_> { } /// Open a domain, which gets an equal share of bytes with every other domain - /// - /// The default domain, holding every group opened without one, is one of them. - pub fn open_domain(&mut self) -> SendDomain { + #[cfg(test)] + pub(crate) fn open_domain(&mut self) -> SendDomain { SendDomain { index: self.state.pending.open_domain(), tag: self.state.tag, @@ -601,7 +611,8 @@ impl SendGroups<'_> { /// /// # Panics /// - when `domain` was opened on another connection - pub fn close_domain(&mut self, domain: SendDomain) { + #[cfg(test)] + pub(crate) fn close_domain(&mut self, domain: SendDomain) { let index = domain.index(self.state.tag); self.state.pending.close_domain(index); } @@ -697,13 +708,15 @@ impl SendGroup { /// A domain of send groups, from [`SendGroups::open_domain`] /// /// Release it with [`SendGroups::close_domain`] on the connection that opened it. +#[cfg(test)] #[derive(Debug, PartialEq, Eq)] #[must_use = "a domain lives until it is closed"] -pub struct SendDomain { +pub(crate) struct SendDomain { index: usize, tag: ConnectionTag, } +#[cfg(test)] impl SendDomain { fn index(&self, tag: ConnectionTag) -> usize { assert_eq!(self.tag, tag, "send domain used on another connection"); diff --git a/rs/moq-quic/src/connection/streams/saturated.rs b/rs/moq-quic/src/connection/streams/saturated.rs index 1000857d00..6e1bbb30df 100644 --- a/rs/moq-quic/src/connection/streams/saturated.rs +++ b/rs/moq-quic/src/connection/streams/saturated.rs @@ -90,7 +90,7 @@ impl Sender { } fn group(&mut self, priority: i64) -> SendGroup { - self.groups().open(None, priority) + self.groups().open(priority) } fn send(&mut self, id: StreamId) -> SendStream<'_> { @@ -448,13 +448,13 @@ fn domains_share_bytes_and_confine_priority() { let quiet = sender.groups().open_domain(); let crowded_groups: Vec<_> = (0..10) - .map(|priority| sender.groups().open(Some(&crowded), priority)) + .map(|priority| sender.groups().open_in(priority, &crowded)) .collect(); let crowded_streams: Vec<_> = crowded_groups .iter() .map(|group| sender.open(Some(group), 0, 1_000_000)) .collect(); - let quiet_group = sender.groups().open(Some(&quiet), -100); + let quiet_group = sender.groups().open_in(-100, &quiet); let quiet_stream = sender.open(Some(&quiet_group), 0, 1_000_000); sender.drain(200); @@ -472,7 +472,7 @@ fn domains_share_bytes_and_confine_priority() { fn the_default_domain_is_one_share() { let mut sender = Sender::new(); let domain = sender.groups().open_domain(); - let inside = sender.groups().open(Some(&domain), 0); + let inside = sender.groups().open_in(0, &domain); let outside: Vec = (0..3).map(|_| sender.group(0)).collect(); let inside = sender.open(Some(&inside), 0, 1_000_000); let outside: Vec<_> = outside @@ -490,7 +490,7 @@ fn the_default_domain_is_one_share() { fn reset_frees_a_released_group() { let mut sender = Sender::new(); let domain: SendDomain = sender.groups().open_domain(); - let group = sender.groups().open(Some(&domain), 0); + let group = sender.groups().open_in(0, &domain); let reset = sender.open(Some(&group), 0, 10_000); let finished = sender.open(Some(&group), 1, 100); sender.send(finished).finish().unwrap(); @@ -673,8 +673,8 @@ fn saturated_datagram_domains_share_bytes() { let mut sender = Sender::new(); let small = sender.groups().open_domain(); let large = sender.groups().open_domain(); - let small = sender.groups().open(Some(&small), 0); - let large = sender.groups().open(Some(&large), 9); + let small = sender.groups().open_in(0, &small); + let large = sender.groups().open_in(9, &large); let small: Vec<_> = (0..2_000) .map(|seq| sender.datagram(Some(&small), seq, 50)) .collect(); diff --git a/rs/moq-quic/src/connection/streams/schedule.rs b/rs/moq-quic/src/connection/streams/schedule.rs index a4c2499e67..1b73fdd08a 100644 --- a/rs/moq-quic/src/connection/streams/schedule.rs +++ b/rs/moq-quic/src/connection/streams/schedule.rs @@ -291,11 +291,13 @@ impl Schedule { } /// Create a domain, sharing bytes equally with every other domain. + #[cfg(test)] pub(super) fn open_domain(&mut self) -> usize { self.domains.insert(Domain::new()) } /// Release a domain; it is freed once its last group is. + #[cfg(test)] pub(super) fn close_domain(&mut self, domain: usize) { assert_ne!(domain, DEFAULT_DOMAIN, "the default domain is never closed"); self.domains[domain].closed = true; diff --git a/rs/moq-quic/src/connection/streams/state.rs b/rs/moq-quic/src/connection/streams/state.rs index 8682edda7c..afdc4185bd 100644 --- a/rs/moq-quic/src/connection/streams/state.rs +++ b/rs/moq-quic/src/connection/streams/state.rs @@ -3359,8 +3359,8 @@ mod tests { fn send_group_from_another_connection() { let mut a = make(Side::Client); let mut b = make(Side::Client); - let group = crate::SendGroups { state: &mut a }.open(None, 0); - let _other = crate::SendGroups { state: &mut b }.open(None, 0); + let group = crate::SendGroups { state: &mut a }.open(0); + let _other = crate::SendGroups { state: &mut b }.open(0); crate::SendGroups { state: &mut b }.set_priority(&group, 1); } @@ -3371,7 +3371,7 @@ mod tests { let mut b = make(Side::Client); let domain = crate::SendGroups { state: &mut a }.open_domain(); let _other = crate::SendGroups { state: &mut b }.open_domain(); - let _ = crate::SendGroups { state: &mut b }.open(Some(&domain), 0); + let _ = crate::SendGroups { state: &mut b }.open_in(0, &domain); } /// Another connection holding at least as much credit still refuses it. diff --git a/rs/moq-quic/src/lib.rs b/rs/moq-quic/src/lib.rs index 4fc4aff619..a52b3f2dd3 100644 --- a/rs/moq-quic/src/lib.rs +++ b/rs/moq-quic/src/lib.rs @@ -55,7 +55,7 @@ mod connection; pub use crate::connection::{ Chunk, Chunks, ClosedStream, Connection, ConnectionError, ConnectionStats, Credit, CreditStats, Datagrams, Event, FinishError, FrameStats, PathStats, ReadError, ReadableError, RecvStream, - RttEstimator, SendDatagramError, SendDomain, SendGroup, SendGroups, SendOptions, SendStream, + RttEstimator, SendDatagramError, SendGroup, SendGroups, SendOptions, SendStream, ShouldTransmit, StreamEvent, Streams, UdpStats, WriteError, Written, }; #[cfg(feature = "qlog")] diff --git a/rs/moq-quic/src/tests/mod.rs b/rs/moq-quic/src/tests/mod.rs index dd11c6ed99..74e8509e48 100644 --- a/rs/moq-quic/src/tests/mod.rs +++ b/rs/moq-quic/src/tests/mod.rs @@ -3339,8 +3339,8 @@ fn first_flight_datagrams(stream: i64, datagram: i64) -> usize { let mut pair = Pair::default(); let (client_ch, server_ch) = pair.connect(); let conn = pair.server_conn_mut(server_ch); - let streams = conn.send_groups().open(None, stream); - let datagrams = conn.send_groups().open(None, datagram); + let streams = conn.send_groups().open(stream); + let datagrams = conn.send_groups().open(datagram); let s = conn .streams() .open( diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index bb9c1c0410..0dc69f2af9 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -832,25 +832,6 @@ pub struct Config { setting = "cluster.tier" )] pub tier: Option, - - /// Share each relay-to-relay session's bandwidth equally between its broadcasts. - /// Boolean flag: pass `--cluster-fair` (or `=true` / `=false`). Off by default. - /// - /// Off, a peer session orders every stream by the downstream subscriber's priority, - /// so one tenant's broadcast can starve another's on a congested link. On, each - /// broadcast gets an equal share however many tracks or subscribers it has, the - /// publisher's track priority alone orders the streams inside it, and the subscriber - /// priorities from downstream are not forwarded. Sessions with clients are unchanged, - /// as are peer sessions over a transport without send domains (WebSocket, iroh) or - /// over moq-transport, which stay strict. - #[usage( - name = "cluster-fair", - long = "cluster-fair", - env = "MOQ_CLUSTER_FAIR", - setting = "cluster.fair", - bool_value - )] - pub fair: bool, } impl Config { @@ -1189,23 +1170,6 @@ impl Cluster { } } - /// How an admitted session shares bandwidth: per broadcast for a `peer` grant when - /// [`Config::fair`] is on, strictly otherwise. - pub(crate) fn schedule(&self, lease: &auth::Lease) -> moq_net::session::Schedule { - match lease.token().peer { - true => self.peer_schedule(), - false => moq_net::session::Schedule::Strict, - } - } - - /// How a relay-to-relay session shares bandwidth. See [`Config::fair`]. - fn peer_schedule(&self) -> moq_net::session::Schedule { - match self.config.fair { - true => moq_net::session::Schedule::Broadcast, - false => moq_net::session::Schedule::Strict, - } - } - /// The QUIC limits an admitted session widens to, if any: only a `peer` grant does. #[cfg(any(feature = "quic", all(target_os = "linux", feature = "_uring")))] pub(crate) fn quic_limits(&self, lease: &auth::Lease) -> Option { @@ -2068,8 +2032,7 @@ impl Cluster { .with_publisher(origin.consume().with_hidden(true)) .with_subscriber(origin) .with_stats(stats.clone()) - .with_limits(self.peer_limits) - .with_schedule(self.peer_schedule()); + .with_limits(self.peer_limits); if let Some(cost) = target.cost { client = client.with_cost(cost); } @@ -2380,31 +2343,6 @@ mod tests { } } - /// `cluster.fair` schedules only relay-to-relay sessions per broadcast. - #[tokio::test] - async fn fair_scheduling_follows_the_peer_grant() { - use moq_net::session::Schedule; - for (fair, peer, expected) in [ - (false, false, Schedule::Strict), - (false, true, Schedule::Strict), - (true, false, Schedule::Strict), - (true, true, Schedule::Broadcast), - ] { - let mut options = Options::default(); - options.config.fair = fair; - let cluster = Cluster::new(options).unwrap(); - let mut grant = moq_auth::Grant::new(moq_auth::Patterns::new(), moq_auth::Patterns::new()); - grant.peer = peer; - let (_producer, consumer) = moq_auth::lease::Producer::new(grant); - let lease = auth::Lease::new("/.cluster/peer", consumer); - assert_eq!(cluster.schedule(&lease), expected, "fair {fair}, peer {peer}"); - assert_eq!( - cluster.peer_schedule(), - if fair { Schedule::Broadcast } else { Schedule::Strict } - ); - } - } - /// A millisecond track: the frames written here carry timestamps. fn timed() -> moq_net::track::Info { moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI) diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index b032d1a6da..72d1d1f140 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -129,8 +129,7 @@ impl Connection { // publish-only or subscribe-only session. let mut request = request .with_stats(stats.clone()) - .with_limits(lifecycle.cluster.limits(&lease)) - .with_schedule(lifecycle.cluster.schedule(&lease)); + .with_limits(lifecycle.cluster.limits(&lease)); if let Some(subscriber) = subscriber { request = request.with_publisher(subscriber); } diff --git a/rs/moq-relay/src/incoming.rs b/rs/moq-relay/src/incoming.rs index 9c5d8b9daf..86ea3e3d49 100644 --- a/rs/moq-relay/src/incoming.rs +++ b/rs/moq-relay/src/incoming.rs @@ -63,7 +63,6 @@ pub trait Sealed: Sized { fn role(&self) -> Option; fn with_stats(self, stats: moq_net::stats::Session) -> Self; fn with_limits(self, limits: moq_net::session::Limits) -> Self; - fn with_schedule(self, schedule: moq_net::session::Schedule) -> Self; fn with_publisher(self, publish: moq_net::origin::Consumer) -> Self; fn with_subscriber(self, subscribe: moq_net::origin::Producer) -> Self; @@ -125,10 +124,6 @@ impl Sealed for moq_tokio::server::Request { moq_tokio::server::Request::with_limits(self, limits) } - fn with_schedule(self, schedule: moq_net::session::Schedule) -> Self { - moq_tokio::server::Request::with_schedule(self, schedule) - } - fn with_publisher(self, publish: moq_net::origin::Consumer) -> Self { moq_tokio::server::Request::with_publisher(self, publish) } @@ -196,11 +191,6 @@ impl Sealed for Accepted { self } - fn with_schedule(mut self, schedule: moq_net::session::Schedule) -> Self { - self.handshake = self.handshake.with_schedule(schedule); - self - } - fn with_publisher(mut self, publish: moq_net::origin::Consumer) -> Self { self.handshake = self.handshake.with_publisher(publish); self diff --git a/rs/moq-relay/src/settings.rs b/rs/moq-relay/src/settings.rs index 340a481162..25ad57508b 100644 --- a/rs/moq-relay/src/settings.rs +++ b/rs/moq-relay/src/settings.rs @@ -97,9 +97,6 @@ struct Cluster { #[usage(env = "MOQ_CLUSTER_TIER", cli("--cluster-tier"))] tier: Option, - #[usage(env = "MOQ_CLUSTER_FAIR", cli("--cluster-fair"))] - fair: Option, - #[cfg(feature = "cluster-lan")] #[usage(flatten)] lan: ClusterLan, diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index cc3c54aa57..a33d429585 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -815,8 +815,7 @@ async fn serve_request( } let mut request = request .with_stats(stats.clone()) - .with_limits(serve.cluster.limits(&lease)) - .with_schedule(serve.cluster.schedule(&lease)); + .with_limits(serve.cluster.limits(&lease)); if let Some(subscriber) = subscriber { request = request.with_publisher(subscriber); } diff --git a/rs/moq-room/MIGRATE.md b/rs/moq-room/MIGRATE.md index 93cef7a3bc..9adccdc898 100644 --- a/rs/moq-room/MIGRATE.md +++ b/rs/moq-room/MIGRATE.md @@ -4,6 +4,6 @@ How to update your code for each breaking change, newest first. ## Unreleased -### `chat::PRIORITY` is signed +### `chat::PRIORITY` is signed (#6142) `chat::PRIORITY` is an `i8`, still 10. diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 35ff2a73ec..446c9f9171 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -210,12 +210,6 @@ impl Client { self } - /// Share each session's bandwidth between broadcasts; see [`moq_net::Client::with_schedule`]. - pub fn with_schedule(mut self, schedule: moq_net::session::Schedule) -> Self { - self.moq = self.moq.with_schedule(schedule); - self - } - /// Attach a per-connection [`moq_net::stats::Session`] context to all sessions /// opened by this client. pub fn with_stats(mut self, stats: moq_net::stats::Session) -> Self { diff --git a/rs/moq-tokio/src/quic/driver/connection.rs b/rs/moq-tokio/src/quic/driver/connection.rs index ba1f00ffcf..6913eb8382 100644 --- a/rs/moq-tokio/src/quic/driver/connection.rs +++ b/rs/moq-tokio/src/quic/driver/connection.rs @@ -28,7 +28,7 @@ use crate::quic::driver::{ mutex::Mutex, recv_stream::RecvStream, runtime::UdpSender, - send_group::{self, SendDomain, SendGroup, SendOptions}, + send_group::{self, SendGroup, SendOptions}, send_stream::SendStream, udp_transmit, }; @@ -342,20 +342,12 @@ impl Connection { } } - /// Open a send domain, which shares bytes equally with the connection's other domains. - pub fn send_domain(&self) -> SendDomain { - SendDomain::new(self.0.0.clone()) - } - - /// Open a send group at `priority` in `domain`, or the connection's default domain, for - /// streams and datagrams to join with [`SendOptions`] or [`SendStream::set_group`]. - /// - /// # Panics - /// - when `domain` belongs to another connection + /// Open a send group at `priority`, for streams and datagrams to join with [`SendOptions`] + /// or [`SendStream::set_group`]. /// /// [`SendStream::set_group`]: crate::quic::driver::SendStream::set_group - pub fn send_group(&self, domain: Option<&SendDomain>, priority: i64) -> SendGroup { - SendGroup::new(self.0.0.clone(), domain, priority) + pub fn send_group(&self, priority: i64) -> SendGroup { + SendGroup::new(self.0.0.clone(), priority) } /// Initiate a new outgoing bidirectional stream, its sending half scheduled by `options`. diff --git a/rs/moq-tokio/src/quic/driver/mod.rs b/rs/moq-tokio/src/quic/driver/mod.rs index 02fc3051ee..5acc245510 100644 --- a/rs/moq-tokio/src/quic/driver/mod.rs +++ b/rs/moq-tokio/src/quic/driver/mod.rs @@ -80,7 +80,7 @@ pub(crate) use crate::quic::driver::runtime::send_failed; #[cfg(test)] pub(crate) use crate::quic::driver::runtime::wrap_udp_socket; pub use crate::quic::driver::runtime::{AsyncUdpSocket, UdpSender}; -pub use crate::quic::driver::send_group::{SendDomain, SendGroup, SendOptions}; +pub use crate::quic::driver::send_group::{SendGroup, SendOptions}; pub use crate::quic::driver::send_stream::{SendStream, StoppedError, WriteError}; #[cfg(test)] diff --git a/rs/moq-tokio/src/quic/driver/send_group.rs b/rs/moq-tokio/src/quic/driver/send_group.rs index 3384ba971a..5270cdd8de 100644 --- a/rs/moq-tokio/src/quic/driver/send_group.rs +++ b/rs/moq-tokio/src/quic/driver/send_group.rs @@ -4,10 +4,9 @@ use crate::quic::driver::connection::ConnectionInner; /// A send group on a [`Connection`](crate::quic::driver::Connection) /// -/// Streams that [join](crate::quic::driver::SendStream::set_group) a group share their -/// [`SendDomain`]'s bytes with its other groups: a higher priority group preempts every lower -/// one, and groups of equal priority get an equal share of bytes however many streams each has -/// queued. A +/// Streams that [join](crate::quic::driver::SendStream::set_group) a group share bytes with +/// the connection's other groups: a higher priority group preempts every lower one, and groups +/// of equal priority get an equal share of bytes however many streams each has queued. A /// stream's own priority orders it only against the group's other streams, and a datagram sent /// in the group is ordered among them by its priority. Streams and datagrams outside any group /// go before every group. See [`moq_quic::SendGroups`]. @@ -35,17 +34,13 @@ struct Inner { } impl SendGroup { - pub(crate) fn new( - conn: Arc, - domain: Option<&SendDomain>, - priority: i64, - ) -> Self { + pub(crate) fn new(conn: Arc, priority: i64) -> Self { let group = conn .state .lock("SendGroup::new") .inner .send_groups() - .open(domain.map(SendDomain::id), priority); + .open(priority); Self(Arc::new(Inner { conn, group: Some(group), @@ -78,49 +73,3 @@ impl Drop for Inner { } } } - -/// A domain of send groups on a [`Connection`](crate::quic::driver::Connection) -/// -/// Domains share the bytes the ungrouped streams leave equally, however many groups each -/// holds, and the groups opened without one form the connection's default domain. A group's -/// priority only orders it against the other groups of its domain. See -/// [`moq_quic::SendGroups::open_domain`]. -/// -/// Clones share one domain, released once the last clone is dropped and its groups are. -#[derive(Debug, Clone)] -pub struct SendDomain(Arc); - -#[derive(Debug)] -struct DomainInner { - conn: Arc, - domain: Option, -} - -impl SendDomain { - pub(crate) fn new(conn: Arc) -> Self { - let domain = conn - .state - .lock("SendDomain::new") - .inner - .send_groups() - .open_domain(); - Self(Arc::new(DomainInner { - conn, - domain: Some(domain), - })) - } - - /// The domain as the connection knows it, which refuses it on any other connection. - fn id(&self) -> &moq_quic::SendDomain { - self.0.domain.as_ref().expect("only taken on drop") - } -} - -impl Drop for DomainInner { - fn drop(&mut self) { - if let Some(domain) = self.domain.take() { - let mut conn = self.conn.state.lock("SendDomain::drop"); - conn.inner.send_groups().close_domain(domain); - } - } -} diff --git a/rs/moq-tokio/src/quic/web/session.rs b/rs/moq-tokio/src/quic/web/session.rs index fec9f299ad..5215933272 100644 --- a/rs/moq-tokio/src/quic/web/session.rs +++ b/rs/moq-tokio/src/quic/web/session.rs @@ -362,22 +362,13 @@ impl Session { Ok(()) } - /// Open a send domain. See [`crate::quic::driver::SendDomain`]. - pub fn send_domain(&self) -> crate::quic::driver::SendDomain { - self.conn.send_domain() - } - - /// Open a send group at `priority` in `domain`, or the connection's default domain, for - /// streams and datagrams to join with [`SendOptions`] or [`SendStream::set_group`]. + /// Open a send group at `priority`, for streams and datagrams to join with [`SendOptions`] + /// or [`SendStream::set_group`]. /// /// See [`crate::quic::driver::SendGroup`]. The HTTP/3 streams stay outside every group, /// so they go first. - pub fn send_group( - &self, - domain: Option<&crate::quic::driver::SendDomain>, - priority: i64, - ) -> crate::quic::driver::SendGroup { - self.conn.send_group(domain, priority) + pub fn send_group(&self, priority: i64) -> crate::quic::driver::SendGroup { + self.conn.send_group(priority) } /// Computes the maximum size of datagrams that may be passed to diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index fe48de87c1..ef43761b9a 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1779,13 +1779,6 @@ impl Request { self } - /// Share the session's bandwidth between broadcasts, after authorizing the peer and - /// before answering SETUP; see [`moq_net::session::Schedule`]. - pub fn with_schedule(mut self, schedule: moq_net::session::Schedule) -> Self { - self.kind = request_map!(self.kind, request => request.with_schedule(schedule)); - self - } - /// The QUIC connection under this request, to change its limits before answering /// SETUP. `None` on a transport moq-quic does not drive (qmux streams, iroh). #[cfg(feature = "quic")] diff --git a/rs/moq-tokio/src/transport.rs b/rs/moq-tokio/src/transport.rs index 971f33bdb2..5f60ad295b 100644 --- a/rs/moq-tokio/src/transport.rs +++ b/rs/moq-tokio/src/transport.rs @@ -878,16 +878,8 @@ impl Grouped for crate::quic::web::Session { Some(crate::quic::driver::Connection::wire(self)) } - fn send_domain(&self) -> Option { - Some(Self::send_domain(self)) - } - - fn send_group( - &self, - domain: Option<&crate::quic::driver::SendDomain>, - priority: i64, - ) -> Option { - Some(Self::send_group(self, domain, priority)) + fn send_group(&self, priority: i64) -> Option { + Some(Self::send_group(self, priority)) } fn send_datagram( @@ -919,8 +911,6 @@ impl GroupedSend for crate::quic::web::SendStream { #[cfg(feature = "quic")] impl moq_net::transport::poll::SendGroup for crate::quic::driver::SendGroup { - type Domain = crate::quic::driver::SendDomain; - fn set_priority(&mut self, priority: i64) { Self::set_priority(self, priority) } @@ -1040,8 +1030,6 @@ mod tests { struct FakeGroup(u32); impl moq_net::transport::poll::SendGroup for FakeGroup { - type Domain = moq_net::transport::poll::Ungrouped; - fn set_priority(&mut self, _: i64) {} } diff --git a/rs/moq-tokio/src/transport/grouped.rs b/rs/moq-tokio/src/transport/grouped.rs index 1ad81a13ba..a3b0bfc9f3 100644 --- a/rs/moq-tokio/src/transport/grouped.rs +++ b/rs/moq-tokio/src/transport/grouped.rs @@ -6,9 +6,6 @@ use moq_net::transport::poll::SendOptions; -/// A backend session's send domain. -pub type Domain = <<::SendStream as GroupedSend>::Group as moq_net::transport::poll::SendGroup>::Domain; - /// A backend session whose send streams may join send groups, as native QUIC's do. /// /// The async interface has no groups or wire counters, so moq-net's @@ -21,18 +18,9 @@ pub trait Grouped: web_transport_trait::Session { None } - /// Open a send domain, or `None` when the backend has none. - fn send_domain(&self) -> Option> { - None - } - - /// Open a send group at `priority` in `domain`, or `None` when the backend has none. - fn send_group( - &self, - domain: Option<&Domain>, - priority: i64, - ) -> Option<::Group> { - let _ = (domain, priority); + /// Open a send group at `priority`, or `None` when the backend has none. + fn send_group(&self, priority: i64) -> Option<::Group> { + let _ = priority; None } @@ -50,7 +38,7 @@ pub trait Grouped: web_transport_trait::Session { /// A backend send stream that may join its [`Grouped`] session's send groups. pub trait GroupedSend: web_transport_trait::SendStream { /// The backend's send group, [`Ungrouped`](moq_net::transport::poll::Ungrouped) when it has none. - type Group: moq_net::transport::poll::SendGroup + Send + Sync; + type Group: moq_net::transport::poll::SendGroup + Send + Sync; /// Move the stream into `group`. fn set_group(&mut self, group: &Self::Group); diff --git a/rs/moq-tokio/src/transport/owned.rs b/rs/moq-tokio/src/transport/owned.rs index 238504ad4f..eec916f342 100644 --- a/rs/moq-tokio/src/transport/owned.rs +++ b/rs/moq-tokio/src/transport/owned.rs @@ -81,15 +81,8 @@ impl> poll::Session }) .map_err(Error) } - fn send_domain(&mut self) -> Option> { - self.session.send_domain() - } - fn send_group( - &mut self, - domain: Option<&poll::DomainOf>, - priority: i64, - ) -> Option<::Group> { - self.session.send_group(domain, priority) + fn send_group(&mut self, priority: i64) -> Option<::Group> { + self.session.send_group(priority) } fn send_datagram( &mut self, diff --git a/rs/moq-uring/src/quic/driver/connection.rs b/rs/moq-uring/src/quic/driver/connection.rs index 5c0d2a0600..1e08132aa2 100644 --- a/rs/moq-uring/src/quic/driver/connection.rs +++ b/rs/moq-uring/src/quic/driver/connection.rs @@ -302,19 +302,11 @@ impl Connection { &self.shared.owner } - /// Open a send domain, which shares bytes equally with the connection's other domains. - pub fn send_domain(&self) -> super::SendDomain { - super::SendDomain::new(self.shared.clone()) - } - - /// Open a send group at `priority` in `domain`, or the connection's default domain, for - /// streams to join with [`SendStream::set_group`](super::SendStream::set_group) and - /// datagrams with [`SendOptions`](super::SendOptions). - /// - /// # Panics - /// - when `domain` belongs to another connection - pub fn send_group(&self, domain: Option<&super::SendDomain>, priority: i64) -> super::SendGroup { - super::SendGroup::new(self.shared.clone(), domain, priority) + /// Open a send group at `priority`, for streams to join with + /// [`SendStream::set_group`](super::SendStream::set_group) and datagrams with + /// [`SendOptions`](super::SendOptions). + pub fn send_group(&self, priority: i64) -> super::SendGroup { + super::SendGroup::new(self.shared.clone(), priority) } /// Send a datagram scheduled by `options`, like one of its group's streams at its priority, diff --git a/rs/moq-uring/src/quic/driver/group.rs b/rs/moq-uring/src/quic/driver/group.rs index 28945118d2..5486108632 100644 --- a/rs/moq-uring/src/quic/driver/group.rs +++ b/rs/moq-uring/src/quic/driver/group.rs @@ -6,8 +6,8 @@ use super::Shared; /// A send group on a [`Connection`](super::Connection). /// -/// Streams that [join](super::SendStream::set_group) a group share their [`SendDomain`]'s -/// bytes with its other groups: a higher priority group preempts every lower one, and groups of +/// Streams that [join](super::SendStream::set_group) a group share bytes with the +/// connection's other groups: a higher priority group preempts every lower one, and groups of /// equal priority get an equal share of bytes however many streams each has queued. A stream's /// own priority orders it only against the group's other streams, and a datagram sent in the /// group is ordered among them by its priority. Streams and datagrams outside any group go @@ -35,12 +35,8 @@ struct Inner { } impl SendGroup { - pub(crate) fn new(shared: Shared, domain: Option<&SendDomain>, priority: i64) -> Self { - let group = shared - .conn - .borrow_mut() - .send_groups() - .open(domain.map(SendDomain::id), priority); + pub(crate) fn new(shared: Shared, priority: i64) -> Self { + let group = shared.conn.borrow_mut().send_groups().open(priority); Self(Rc::new(Inner { shared, group: Some(group), @@ -81,48 +77,3 @@ impl std::fmt::Debug for SendGroup { f.debug_struct("SendGroup").finish_non_exhaustive() } } - -/// A domain of send groups on a [`Connection`](super::Connection). -/// -/// Domains share the bytes the ungrouped streams leave equally, however many groups each -/// holds, and the groups opened without one form the connection's default domain. A group's -/// priority only orders it against the other groups of its domain. See -/// [`moq_quic::SendGroups::open_domain`]. -/// -/// Clones share one domain, released once the last clone is dropped and its groups are. -#[derive(Clone)] -pub struct SendDomain(Rc); - -struct DomainInner { - shared: Shared, - domain: Option, -} - -impl SendDomain { - pub(crate) fn new(shared: Shared) -> Self { - let domain = shared.conn.borrow_mut().send_groups().open_domain(); - Self(Rc::new(DomainInner { - shared, - domain: Some(domain), - })) - } - - /// The domain as the connection knows it, which refuses it on any other connection. - fn id(&self) -> &moq_quic::SendDomain { - self.0.domain.as_ref().expect("only taken on drop") - } -} - -impl Drop for DomainInner { - fn drop(&mut self) { - if let Some(domain) = self.domain.take() { - self.shared.conn.borrow_mut().send_groups().close_domain(domain); - } - } -} - -impl std::fmt::Debug for SendDomain { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SendDomain").finish_non_exhaustive() - } -} diff --git a/rs/moq-uring/src/quic/driver/mod.rs b/rs/moq-uring/src/quic/driver/mod.rs index 30a1b154e9..a5270c9699 100644 --- a/rs/moq-uring/src/quic/driver/mod.rs +++ b/rs/moq-uring/src/quic/driver/mod.rs @@ -16,7 +16,7 @@ mod stream; pub use connection::Connection; pub use endpoint::Endpoint; -pub use group::{SendDomain, SendGroup, SendOptions}; +pub use group::{SendGroup, SendOptions}; pub use stream::{Credit, RecvStream, SendStream}; pub(crate) use connection::{End, Shared}; diff --git a/rs/moq-uring/src/quic/mod.rs b/rs/moq-uring/src/quic/mod.rs index 6dafc832f6..0b918d1492 100644 --- a/rs/moq-uring/src/quic/mod.rs +++ b/rs/moq-uring/src/quic/mod.rs @@ -30,7 +30,7 @@ pub mod web; mod driver; -pub use driver::{Connection, Credit, RecvStream, SendDomain, SendGroup, SendOptions, SendStream}; +pub use driver::{Connection, Credit, RecvStream, SendGroup, SendOptions, SendStream}; pub use endpoint::Endpoint; /// The flow-control limits a live connection can change; see [`Connection::set_limits`]. pub use moq_quic::{Limits, LimitsError}; diff --git a/rs/moq-uring/src/quic/web.rs b/rs/moq-uring/src/quic/web.rs index 221cd9a88c..e644043da0 100644 --- a/rs/moq-uring/src/quic/web.rs +++ b/rs/moq-uring/src/quic/web.rs @@ -450,16 +450,11 @@ impl Session { self.conn.wire() } - /// Open a send domain. See [`super::SendDomain`]. - pub fn send_domain(&self) -> super::SendDomain { - self.conn.send_domain() - } - - /// Open a send group at `priority` in `domain`, or the connection's default domain, for - /// streams to join with [`SendStream::set_group`]. The HTTP/3 streams stay outside every + /// Open a send group at `priority`, for streams to join with + /// [`SendStream::set_group`]. The HTTP/3 streams stay outside every /// group, so they go first. - pub fn send_group(&self, domain: Option<&super::SendDomain>, priority: i64) -> super::SendGroup { - self.conn.send_group(domain, priority) + pub fn send_group(&self, priority: i64) -> super::SendGroup { + self.conn.send_group(priority) } /// Send a datagram scheduled by `options`, like one of its group's streams. diff --git a/rs/moq-uring/src/transport.rs b/rs/moq-uring/src/transport.rs index 4bb5bc707e..12efe45010 100644 --- a/rs/moq-uring/src/transport.rs +++ b/rs/moq-uring/src/transport.rs @@ -15,12 +15,8 @@ impl Grouped for quic::web::Session { Some(quic::web::Session::wire(self)) } - fn send_domain(&mut self) -> Option { - Some(quic::web::Session::send_domain(self)) - } - - fn send_group(&mut self, domain: Option<&quic::SendDomain>, priority: i64) -> Option { - Some(quic::web::Session::send_group(self, domain, priority)) + fn send_group(&mut self, priority: i64) -> Option { + Some(quic::web::Session::send_group(self, priority)) } fn send_datagram( @@ -51,8 +47,6 @@ impl GroupedSend for quic::web::SendStream { #[cfg(feature = "quic")] impl moq_net::transport::poll::SendGroup for quic::SendGroup { - type Domain = quic::SendDomain; - fn set_priority(&mut self, priority: i64) { quic::SendGroup::set_priority(self, priority) } diff --git a/rs/moq-uring/src/transport/adapter.rs b/rs/moq-uring/src/transport/adapter.rs index ac3cf61a4e..b2e2fa32c1 100644 --- a/rs/moq-uring/src/transport/adapter.rs +++ b/rs/moq-uring/src/transport/adapter.rs @@ -47,9 +47,6 @@ impl moq::Stats for Stats { self.0.estimated_send_rate() } } -/// A backend session's send domain. -pub type Domain = <<::SendStream as GroupedSend>::Group as poll::SendGroup>::Domain; - /// A backend session whose send streams may join send groups, as native QUIC's do. /// /// `pub` only because `pub(crate)` is refused (E0446): the adapter's `type Group` projects @@ -65,18 +62,9 @@ pub trait Grouped: backend::Session { None } - /// Open a send domain, or `None` when the backend has none. - fn send_domain(&mut self) -> Option> { - None - } - - /// Open a send group at `priority` in `domain`, or `None` when the backend has none. - fn send_group( - &mut self, - domain: Option<&Domain>, - priority: i64, - ) -> Option<::Group> { - let _ = (domain, priority); + /// Open a send group at `priority`, or `None` when the backend has none. + fn send_group(&mut self, priority: i64) -> Option<::Group> { + let _ = priority; None } @@ -153,15 +141,8 @@ impl + Clone + 'static> pol .map_err(Error) .map(|res| res.map(|(send, recv)| (SendStream(send).scheduled(options), RecvStream(recv)))) } - fn send_domain(&mut self) -> Option> { - Grouped::send_domain(&mut self.0) - } - fn send_group( - &mut self, - domain: Option<&poll::DomainOf>, - priority: i64, - ) -> Option<::Group> { - Grouped::send_group(&mut self.0, domain, priority) + fn send_group(&mut self, priority: i64) -> Option<::Group> { + Grouped::send_group(&mut self.0, priority) } fn send_datagram( &mut self, diff --git a/rs/moq-uring/src/transport/local.rs b/rs/moq-uring/src/transport/local.rs index b02e3c9c68..7c42bcb9b7 100644 --- a/rs/moq-uring/src/transport/local.rs +++ b/rs/moq-uring/src/transport/local.rs @@ -63,18 +63,8 @@ impl poll::Session for Local { .map_ok(|(send, recv)| (Local::new(send), Local::new(recv))) } - fn send_domain(&mut self) -> Option> { - self.0.send_domain().map(Local::new) - } - - fn send_group( - &mut self, - domain: Option<&poll::DomainOf>, - priority: i64, - ) -> Option::Group>> { - self.0 - .send_group(domain.map(|domain| &*domain.0), priority) - .map(Local::new) + fn send_group(&mut self, priority: i64) -> Option::Group>> { + self.0.send_group(priority).map(Local::new) } fn send_datagram( @@ -176,8 +166,6 @@ impl poll::RecvStream for Local { } impl poll::SendGroup for Local { - type Domain = Local; - fn set_priority(&mut self, priority: i64) { self.0.set_priority(priority) } diff --git a/rs/moq-wasm/src/transport/adapter.rs b/rs/moq-wasm/src/transport/adapter.rs index 8028b140ed..7b6a08e2e1 100644 --- a/rs/moq-wasm/src/transport/adapter.rs +++ b/rs/moq-wasm/src/transport/adapter.rs @@ -85,10 +85,7 @@ impl + Clone + 'st .map_err(Error) .map(|res| res.map(|(send, recv)| (SendStream(send).scheduled(options), RecvStream(recv)))) } - fn send_domain(&mut self) -> Option { - None - } - fn send_group(&mut self, _: Option<&poll::Ungrouped>, _: i64) -> Option { + fn send_group(&mut self, _: i64) -> Option { None } /// The backend cannot rank a datagram against a stream, so `options` are ignored, and a diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index b959d2c299..bd6268aa57 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,7 +4,7 @@ How to update your code for each breaking change, newest first. ## Unreleased -### Priorities are signed +### Priorities are signed (#6142) `TrackInfo.priority`, `Subscription.priority`, `FetchGroupOptions.priority`, and `GroupRequest.priority` are `Int8`, higher first, and 0 is the unset midpoint. `TrackInfo.priority` defaulted to 127 and now defaults to 0. From 3c5002764b3dc61c5956ef86e7c7d09910ac1b5d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 21:12:53 -0700 Subject: [PATCH 05/11] fix(cli): keep the report track below every media priority Co-Authored-By: Claude Opus 5.5 --- rs/moq-cli/src/report.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-cli/src/report.rs b/rs/moq-cli/src/report.rs index 9c6a350b4f..3759fdfb5d 100644 --- a/rs/moq-cli/src/report.rs +++ b/rs/moq-cli/src/report.rs @@ -36,7 +36,7 @@ const MAX_REPORT: usize = 1024 * 1024; const DRAIN: Duration = Duration::from_secs(5); /// Below every media track, so a report never delays the media it describes. -const PRIORITY: u8 = 0; +const PRIORITY: i8 = i8::MIN; /// A snapshot track and its compressed `.z` sibling, written together. Either is `None` once a /// write failed and closed it. From bbf1cdbdb07583c6bb22b01fd3a8bbb57abc52b6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 21:33:48 -0700 Subject: [PATCH 06/11] test(net): expect the signed IETF priority byte in TRACK_STATUS interop Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/test_interop.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs index a72b0dd148..3d720234c9 100644 --- a/rs/moq-net/src/test_interop.rs +++ b/rs/moq-net/src/test_interop.rs @@ -437,8 +437,8 @@ fn ietf_track_status_interop() { dynamic_groups: false, max_cache_duration: Some(std::time::Duration::from_millis(5000)), timescale: Some(crate::Timescale::MILLI), - // The JS track's priority 2, lower-first on the wire. - priority: Some(u8::MAX - 2), + // The JS track's priority 2, lower-first on the wire as `127 - 2`. + priority: Some(125), group_order: Some(ietf::GroupOrder::Descending), }, }; From b2498598f919e74e42e3b3d78a64746ff0c4e593 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 10:56:19 -0700 Subject: [PATCH 07/11] fix(js/net): legacy lite subscriptions rank by the publisher priority Drafts before lite-05 served before the track's info resolved, so their groups ignored the publisher tiebreak that Rust applies on every draft. Wait for the info on every draft, as Rust does. Also qualify the even bandwidth split by publisher priority, name which former default was the midpoint, and widen before subtracting in the Swift migration. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 2 +- js/net/MIGRATE.md | 2 +- js/net/src/lite/publisher.test.ts | 27 ++++++++++++++++++++------- js/net/src/lite/publisher.ts | 15 +++++++-------- rs/moq-net/MIGRATE.md | 2 +- swift/MIGRATE.md | 2 +- 6 files changed, 31 insertions(+), 19 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index e6f8b7a90d..b8f592b4c6 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -233,7 +233,7 @@ Each subscription carries the knobs that decide behavior under congestion: | Knob | Effect | | --- | --- | -| **Priority** (-128..127, default 0) | Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement. The publisher's track priority breaks ties between subscriptions at one priority. From a native Rust publisher over QUIC or WebTransport (iroh excepted), subscriptions at the same priority split bandwidth evenly by bytes, streams and datagrams alike, however many groups each has queued. A browser publisher and other transports without send groups are not covered. | +| **Priority** (-128..127, default 0) | Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement. The publisher's track priority breaks ties between subscriptions at one priority. From a native Rust publisher over QUIC or WebTransport (iroh excepted), subscriptions at the same priority and publisher priority split bandwidth evenly by bytes, streams and datagrams alike, however many groups each has queued. A browser publisher and other transports without send groups are not covered. | | **Order** | Which group to send first when several are pending. Newest first for live (the default), oldest first for catch-up. Needs moq-lite 07 or moq-transport; older moq-lite always sends newest first. | | **Max delay** | How long a non-latest group stays worth delivering before it is skipped. Zero means "live edge only", and raising it is also what asks for history. | | **Ranges** | Which groups to ask for: from a group, a number of groups back from the latest, or the group containing a presentation time, optionally up to an end. The default asks for everything max delay allows. Max delay still caps every range. | diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index f1bbdd5be6..0b2e541c3e 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -7,7 +7,7 @@ How to update your code for each breaking change, newest first. ### Priorities are signed (#6142) `Track.Info.priority`, `Track.Subscription.priority`, and a fetch's `priority` take `-128..=127`, higher first, and 0 is the unset midpoint; anything outside throws a `RangeError`. -The track's default was 127 and the subscription's 0, the least urgent; both are 0 now. +The track's default was 127, the midpoint, and the subscription's was 0, the least urgent; both are 0 now. To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. The wire messages (`Lite.Subscribe`, `Lite.TrackInfo`, `Ietf.Subscribe`, ...) hold the same signed priority, mapped to the wire byte when encoded. diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index f162695e4a..16e704e793 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -247,18 +247,24 @@ async function servingNextGroup(opened: ReadableStreamDefaultReader new GroupProducer(sequence)); const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); - const publisher = new Publisher(pair.server, Version.DRAFT_05, randomHop(), origin.consume()); + const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); const broadcast = publish(origin, Path.from("test")); - const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); + const track = broadcast.createTrack("video", { timescale: Timescale.MILLI, priority: options.publisher }); - const client = await Stream.open(pair.client, { version: Version.DRAFT_05 }); - const server = await Stream.accept(pair.server, Version.DRAFT_05); + const client = await Stream.open(pair.client, { version }); + const server = await Stream.accept(pair.server, version); if (!server) throw new Error("publisher never accepted the subscribe stream"); const msg = replaySubscribe({ @@ -275,7 +281,7 @@ async function groupSendOrders(options: { priority: number; sequences: number[]; try { for (const [index, group] of groups.entries()) { if (index === 1 && update !== undefined) { - await replayUpdate({ priority: update }).encode(client.writer, Version.DRAFT_05); + await replayUpdate({ priority: update }).encode(client.writer, version); // Wait for the publisher to apply it, rather than assuming it beat the next group. while (track.subscription.peek()?.priority !== update) await track.subscription.changed(); } @@ -324,6 +330,13 @@ test("lite draft-05: equal priorities tie regardless of group numbering", async expect(fresh).toEqual(ongoing); }); +// The track's publisher priority breaks ties on every draft, as in Rust, including the drafts +// before lite-05 that acknowledge with SUBSCRIBE_OK instead of TRACK_INFO. +test.each([Version.DRAFT_03, Version.DRAFT_05])("lite %s: the publisher priority ranks each group", async (version) => { + const orders = await groupSendOrders({ priority: 7, publisher: 5, sequences: [0], version }); + expect(orders).toEqual([sendOrder({ priority: 7, publisher: 5 })]); +}); + // SUBSCRIBE_UPDATE re-ranks the subscription, so every group it is serving picks up the new // priority, including one opened after the update landed. test("lite draft-05: a subscribe update re-ranks the whole subscription", async () => { diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 9d98671960..758dd2649c 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -853,11 +853,14 @@ export class Publisher { const served: Run[] | undefined = hasTrackStream(this.version) ? [] : undefined; try { + // Every draft waits for the track's info, as Rust does: it surfaces a rejected track + // (accept never called, track closed) as an error here, which resets the stream, and + // its priority breaks ties between this session's subscriptions at one subscriber + // priority. + const info = await untilAborted(track.info(), left.signal); + const publisher = info.priority; // Older drafts carry no timestamps. let timescale: Timescale | undefined; - // Breaks ties between this session's subscriptions at one subscriber priority. Drafts - // before lite-05 serve before the track's info resolves, so they rank by the default. - let publisher = 0; if (hasTrackStream(this.version)) { // Lite-05+ accepts implicitly: no SUBSCRIBE_OK (the immutable @@ -866,12 +869,8 @@ export class Publisher { // // The timescale is an immutable property, so serving MUST use exactly // what TRACK_INFO advertised. It comes from the producer's accept(), so - // they always agree. Awaiting info() also surfaces a rejected track - // (accept never called, track closed) as an error here, which resets the - // stream. - const info = await untilAborted(track.info(), left.signal); + // they always agree. timescale = wireTimescale(this.version, info.timescale); - publisher = info.priority; } else { // Older drafts acknowledge with SUBSCRIBE_OK and stream frames verbatim. const ok = new SubscribeOk({ diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index c15b618259..7f1c3e2d55 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -7,7 +7,7 @@ How to update your code for each breaking change, newest first. ### Priorities are signed, with 0 unset (#6142) `track::Info::priority`, `track::Subscription::priority`, `group::Fetch::priority`, and `group::Request::priority()` are `i8`, higher first, and 0 is the unset midpoint. -The track's default was 127 and the subscription's 0, the least urgent; both are 0 now. +The track's default was 127, the midpoint, and the subscription's was 0, the least urgent; both are 0 now. To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. The publisher's `Info::priority` now breaks ties between subscriptions at one subscriber priority. diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index ff556a6845..1425bc4521 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -8,7 +8,7 @@ How to update your code for each breaking change, newest first. `TrackInfo.priority`, `Subscription.priority`, `FetchGroupOptions.priority`, and `GroupRequest.priority` are `Int8`, higher first, and 0 is the unset midpoint. `TrackInfo.priority` defaulted to 127 and now defaults to 0. -To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. +To keep a value's moq-lite byte, subtract 128 from it, widening first so it doesn't trap (`Int8(Int16(old) - 128)`): 255 becomes 127, 128 becomes 0, and 0 becomes -128. ### A claim answers by publishing (#5678) From 981ad0b861d74917809d2bc23f88015a7c854ed7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 11:07:52 -0700 Subject: [PATCH 08/11] fix(net): give main's new eviction test a publisher priority Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/publisher.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 85b2d4054a..afd7c32c1b 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -5196,6 +5196,7 @@ mod serve_group_test { id: 0, track_name: "test".into(), priority: PriorityQueue::default(), + publisher: 0, group: None, steer: steer.consume(), steer_seen: Steer::default(), From 396511625b0de0747920d3f84bcbbf3ba47483f8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 11:36:14 -0700 Subject: [PATCH 09/11] doc(cpp): migrate C++ callers to signed priorities Co-Authored-By: Claude Opus 5.5 --- cpp/moq/MIGRATE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index bc35c44067..96f8e37843 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Priorities are signed (#6142) + +`TrackInfo::priority`, `Subscription::priority`, and `FetchGroupOptions::priority` are `int8_t`, and `GroupRequest::priority()` returns one. +Higher is first and 0 is the unset midpoint; `TrackInfo::priority` defaulted to 127 and now defaults to 0. +To keep a value's moq-lite byte, subtract 128 from it, widening first (`static_cast(int(old) - 128)`): 255 becomes 127, 128 becomes 0, and 0 becomes -128. + ### A claim answers by publishing (#5678) `OriginProducer::dynamic(prefix)` takes no route, and `OriginDynamic::update` is gone. From ddf000fb98a893d3d6e5029bd5ae9d75e7c70e1a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 17:59:20 -0700 Subject: [PATCH 10/11] test(go): drop the midpoint priority from main's new status tests Priority 127 is now the most urgent; the zero value is the unset midpoint. Co-Authored-By: Claude Opus 5.5 --- go/wrapper/moq_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 6494df9bb5..0eae289369 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -159,7 +159,7 @@ func TestBroadcastFinishLingersUntilCancelled(t *testing.T) { t.Fatal(err) } maxAge := time.Hour - track, err := broadcast.PublishTrack("status", &moq.TrackInfo{Priority: 127, MaxAge: &maxAge}) + track, err := broadcast.PublishTrack("status", &moq.TrackInfo{MaxAge: &maxAge}) if err != nil { t.Fatal(err) } @@ -192,7 +192,7 @@ func TestBroadcastFinishWaitOnDoneContextCloses(t *testing.T) { t.Fatal(err) } maxAge := time.Hour - track, err := broadcast.PublishTrack("status", &moq.TrackInfo{Priority: 127, MaxAge: &maxAge}) + track, err := broadcast.PublishTrack("status", &moq.TrackInfo{MaxAge: &maxAge}) if err != nil { t.Fatal(err) } From f733f907035334ee88299711ea90932a29d76401 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 22:52:44 -0700 Subject: [PATCH 11/11] quest: repoint main's new links to the completed priority quest Main's audit linked quest/m1/track-priority-scope.md from five quests; the send domain now lives in the m2 broadcast fairness quest, and the signed priority already reached the hand-written C ABI here. Co-Authored-By: Claude Opus 5.5 --- quest/m1/c/README.md | 3 ++- quest/m1/quic/README.md | 2 +- quest/m1/quic/receive-memory.md | 2 +- quest/m1/rs-request-credit.md | 2 +- quest/m2/audio-bufferbloat-av.md | 5 +++-- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/quest/m1/c/README.md b/quest/m1/c/README.md index 9715f158b7..74fbb0ad56 100644 --- a/quest/m1/c/README.md +++ b/quest/m1/c/README.md @@ -52,12 +52,13 @@ That change owns its C tests and `MIGRATE.md` entries; other API reshaping stays Decided 2026-10-20 (maintainer): m1 quests that change the C API ([exact binding timestamps](/quest/m1/binding-timestamps.md), -[scope track priority](/quest/m1/track-priority-scope.md), [binding linger](/quest/m1/linger-ffi.md), [reference epoch bindings](/quest/m1/reference-epoch-bindings.md), [Rust identity enforcement](/quest/m1/catalog-identity-rs.md), and [dynamic subscribe ranges](/quest/m1/subscribe-ranges/dynamic.md)) Require this line and land their C half on the generated package, so C users break once. +Signed priorities already reached the hand-written ABI as `int8_t` in +[moq-dev/moq#6142](https://github.com/moq-dev/moq/pull/6142). ## Required diff --git a/quest/m1/quic/README.md b/quest/m1/quic/README.md index 4c1bef4d00..12bcfcee2d 100644 --- a/quest/m1/quic/README.md +++ b/quest/m1/quic/README.md @@ -10,7 +10,7 @@ reliable stream resets (#5690), hierarchical send groups (#5711, #5770), one `Limits` type for relay peers (#5871), and endpoint sharding (#5688) are on `main`. What remains is bounded incomplete-message memory; per-broadcast fairness gives the crate-private send domains (#5872) their consumer in -[Scope track priority](/quest/m1/track-priority-scope.md). +[Broadcast fairness](/quest/m2/broadcast-fairness.md). qmux on the shared stream state machine is its own m1 quest, [quic-qmux](/quest/m1/quic-qmux.md), outside this epic. Per-stream acknowledgment progress, per-stream deadlines, and the experiments diff --git a/quest/m1/quic/receive-memory.md b/quest/m1/quic/receive-memory.md index 770670182c..b90b9b8f1f 100644 --- a/quest/m1/quic/receive-memory.md +++ b/quest/m1/quic/receive-memory.md @@ -25,7 +25,7 @@ Decided 2026-10-20 (maintainer): three quests break moq-net's `transport::poll` traits, so they land in order and each rebases its trait change on the last: this quest first, then [Rust request credit](/quest/m1/rs-request-credit.md), then -[Scope track priority](/quest/m1/track-priority-scope.md). +the send domain in [broadcast fairness](/quest/m2/broadcast-fairness.md). That primitive landed as a provisional seam to finalize here: both native drivers' receive streams have a copying `poll_read_held` that yields a diff --git a/quest/m1/rs-request-credit.md b/quest/m1/rs-request-credit.md index b37aeb6e23..baf36826bc 100644 --- a/quest/m1/rs-request-credit.md +++ b/quest/m1/rs-request-credit.md @@ -40,7 +40,7 @@ variant, and possibly a `transport::poll` session method. Wire: none. Decided 2026-10-20 (maintainer): the `transport::poll` trait breaks land in order, [receive memory](/quest/m1/quic/receive-memory.md) first, then this -quest, then [Scope track priority](/quest/m1/track-priority-scope.md), so +quest, then the send domain in [broadcast fairness](/quest/m2/broadcast-fairness.md), so implementers rebase on one shape at a time. ## Required diff --git a/quest/m2/audio-bufferbloat-av.md b/quest/m2/audio-bufferbloat-av.md index e71ba417bd..fc002775c8 100644 --- a/quest/m2/audio-bufferbloat-av.md +++ b/quest/m2/audio-bufferbloat-av.md @@ -41,8 +41,9 @@ Recommendations: - Budgets come from nightly runs by the rule in `budgets.json`'s note, not a developer machine. The native `pulse` ceilings there are provisional developer-host numbers; re-measure them from nightlies before enforcing. -- [Track priority scope](/quest/m1/track-priority-scope.md) renumbers - priorities; assert on behavior, not on the 80 and 60 constants. +- Priorities are signed since + [moq-dev/moq#6142](https://github.com/moq-dev/moq/pull/6142); assert on + behavior, not on the 80 and 60 constants. - Update the harness README's "Not covered" list, which names video. Public API: none. Wire: none.