From a1e7131296e6729e52fd1454ca9537603f34cb50 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 21:22:13 -0700 Subject: [PATCH 01/12] chore: claim data consumer timestamps quest Co-Authored-By: GPT-6 From 6730029025fc3ff8e04cf99b529baf5283ba921f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 21:36:00 -0700 Subject: [PATCH 02/12] feat(data)!: retain timestamps and ordered snapshot states Rust JSON/Flate next returns Timed values, snapshots add latest, and mux wrappers mirror reads. Catalog/stats callers retain latest-state behavior. Both snapshot bugs reproduced on base; 229 JSON/Flate tests passed before final compressed-cursor strengthening. Wider Rust caller check in progress. FFI/C/bindings held for exact timestamp representation and C read API decision; full scoped check and all-language interop not run yet. Co-Authored-By: GPT-6 --- rs/hang/src/catalog/consumer.rs | 4 +- rs/moq-boy/src/input.rs | 4 +- rs/moq-flate/src/lib.rs | 4 +- rs/moq-flate/src/snapshot/consumer.rs | 222 ++++++++++++++------- rs/moq-flate/src/snapshot/mod.rs | 55 ++++-- rs/moq-flate/src/snapshot/producer.rs | 3 +- rs/moq-flate/src/stream/consumer.rs | 60 +++++- rs/moq-flate/src/stream/mod.rs | 6 +- rs/moq-json/examples/telemetry.rs | 10 +- rs/moq-json/src/lib.rs | 4 +- rs/moq-json/src/snapshot/consumer.rs | 250 +++++++++++++++++------- rs/moq-json/src/snapshot/mod.rs | 77 +++++--- rs/moq-json/src/stream/consumer.rs | 60 +++++- rs/moq-json/src/stream/mod.rs | 6 +- rs/moq-mux/src/binary.rs | 21 +- rs/moq-mux/src/catalog/hang/consumer.rs | 2 +- rs/moq-mux/src/json.rs | 21 +- rs/moq-stats/src/aggregate.rs | 8 +- rs/moq-stats/src/consume.rs | 8 +- 19 files changed, 597 insertions(+), 228 deletions(-) diff --git a/rs/hang/src/catalog/consumer.rs b/rs/hang/src/catalog/consumer.rs index aa67dd7c70a..92f9f313969 100644 --- a/rs/hang/src/catalog/consumer.rs +++ b/rs/hang/src/catalog/consumer.rs @@ -36,7 +36,9 @@ impl Consumer { /// Poll for the next catalog update. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { - let catalog = ready!(self.inner.poll_next(waiter)).map_err(|err| crate::Error::Json(err.to_string()))?; + let catalog = ready!(self.inner.poll_latest(waiter)) + .map_err(|err| crate::Error::Json(err.to_string()))? + .map(|value| value.value); if let Some(catalog) = catalog.as_ref() { catalog.check_renditions()?; check_resolvable(&self.base, catalog)?; diff --git a/rs/moq-boy/src/input.rs b/rs/moq-boy/src/input.rs index c331e0914d3..a4d6e1b24ac 100644 --- a/rs/moq-boy/src/input.rs +++ b/rs/moq-boy/src/input.rs @@ -129,8 +129,8 @@ async fn handle_viewer_commands( moq_json::snapshot::Consumer::::new(track, moq_json::snapshot::consumer::Config::default()); loop { - let command = match commands.next().await { - Ok(Some(command)) => command, + let command = match commands.latest().await { + Ok(Some(command)) => command.value, Ok(None) => break, // A malformed command is skipped; a track error ends the stream. Err(moq_json::Error::Json(err)) => { diff --git a/rs/moq-flate/src/lib.rs b/rs/moq-flate/src/lib.rs index 479cd8e02b2..e0dbf8011ca 100644 --- a/rs/moq-flate/src/lib.rs +++ b/rs/moq-flate/src/lib.rs @@ -2,8 +2,8 @@ //! //! Two track modes carry the payloads: //! -//! - [`snapshot`]: **lossy**. One value updated over time; a consumer only gets the most recent -//! one. Older values are superseded and dropped. +//! - [`snapshot`]: **lossy**. One value updated over time; `next` yields states inside the drift +//! budget, while `latest` skips to the newest state. Superseded groups may be lost. //! - [`stream`]: **lossless**. An ordered append-log of self-contained payloads, delivered in order //! with nothing superseded. Bounded by the group budget: see [`stream`] for what happens once //! it is spent. diff --git a/rs/moq-flate/src/snapshot/consumer.rs b/rs/moq-flate/src/snapshot/consumer.rs index 9ce149d1112..7b4604fc32b 100644 --- a/rs/moq-flate/src/snapshot/consumer.rs +++ b/rs/moq-flate/src/snapshot/consumer.rs @@ -1,4 +1,4 @@ -//! Consuming an opaque value from a track. +//! Consuming opaque values and their timestamps from a track. use std::task::Poll; @@ -8,10 +8,7 @@ use crate::Result; pub use super::Config; -/// Consumes an opaque value from a track, yielding the newest one. -/// -/// Jumps to the newest group and reads the value out of it, so a late joiner starts at the current -/// value rather than replaying superseded ones. +/// Consumes opaque states in order with `next`, or skips to the newest with `latest`. pub struct Consumer { track: moq_net::track::Ordered, group: Option, @@ -34,84 +31,85 @@ impl Consumer { } } - /// Get the next value, or `None` once the track ends. - pub async fn next(&mut self) -> Result> { + /// Get the next state and its exact frame timestamp, or `None` once the track ends. + pub async fn next(&mut self) -> Result>> { kio::wait(|waiter| self.poll_next(waiter)).await } - /// Poll for the next value, without blocking. + /// Poll for each state in order, retaining the subscription's drift budget. /// - /// Jumps to the newest group and drains everything buffered in it, yielding only the last value: - /// the earlier ones are already superseded, so a consumer that has fallen behind catches up to - /// the head in a single step. A compressed group's frames are still decoded in order, since they - /// share one window; only the yield is skipped. Switching to a newer group discards the older one. + /// Finish the current group before taking the next. Lost groups resynchronize from a + /// later snapshot. A timed playhead reader sets its subscription's `max_delay` to its + /// distance behind live; the default zero budget keeps only groups not yet superseded. + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { + self.poll_read(waiter, false) + } + + /// Skip to the newest buffered state and its timestamp, or wait for a new one. + pub async fn latest(&mut self) -> Result>> { + kio::wait(|waiter| self.poll_latest(waiter)).await + } + + /// Poll for the newest state, discarding superseded groups and intermediate yields. /// - /// A group the transport can no longer serve is discarded the same way, not reported: on a - /// snapshot track its content is superseded by definition, so the reader waits for the - /// replacement. Only a failure of the track itself ends the stream. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { - // Drain to the newest group, starting a cold window whenever we switch. - let track_finished = loop { - match self.track.poll_next_group(waiter)? { - Poll::Ready(Some(group)) => { - self.group = Some(group); - self.flate = self.compression.then(crate::Decoder::new); - } - Poll::Ready(None) => break true, - Poll::Pending => break false, + /// Frames inside the newest group are still decoded in order. What this skips is gone; + /// a subsequent `next` resumes after it. An untimed frame keeps `at: None`. + pub fn poll_latest(&mut self, waiter: &kio::Waiter) -> Poll>>> { + self.poll_read(waiter, true) + } + + fn poll_read(&mut self, waiter: &kio::Waiter, skip: bool) -> Poll>>> { + if skip && let Some(latest) = self.track.latest() { + if self.group.as_ref().is_some_and(|group| group.sequence < latest) { + self.group = None; } - }; + if self.group.is_none() { + self.track.set_groups(latest..); + } + } - // Decode every frame currently buffered in the group, keeping only the last. - // `poll_read_frame` returns an owned `Poll`, so the borrow of `self.group` ends before the - // match arms, leaving `decode` (and clearing the group) free to take `&mut self`. let mut latest = None; - let mut group_pending = false; - while let Some(group) = &mut self.group { + loop { + let Some(group) = &mut self.group else { + match self.track.poll_next_group(waiter)? { + Poll::Ready(Some(group)) => { + self.group = Some(group); + self.flate = self.compression.then(crate::Decoder::new); + continue; + } + Poll::Ready(None) => return Poll::Ready(Ok(latest)), + Poll::Pending => break, + } + }; + match group.poll_read_frame(waiter) { - Poll::Ready(Ok(Some(frame))) => latest = Some(self.decode(&frame.payload)?), - // The current group is exhausted; wait for a newer one. - Poll::Ready(Ok(None)) => { - self.group = None; - break; + Poll::Ready(Ok(Some(frame))) => { + let value = moq_net::Timed { + value: self.decode(&frame.payload)?, + at: frame.timestamp, + }; + if !skip { + return Poll::Ready(Ok(Some(value))); + } + latest = Some(value); } - // The transport can no longer serve the rest of this group: it was superseded and - // reclaimed (`Old`), dropped under memory pressure (`Evicted`), or read past the - // drift budget (`Lagged`). A snapshot reader only ever wants the newest value, so a - // group whose content is gone is never fatal: drop it and wait for its replacement. - // A track- or session-level failure still arrives through `poll_next_group` above. - Poll::Ready(Err(err)) => { - let sequence = group.sequence; + Poll::Ready(Ok(None)) => self.group = None, + Poll::Ready(Err( + moq_net::Error::Old + | moq_net::Error::Evicted + | moq_net::Error::Lagged + | moq_net::Error::GroupTooLarge, + )) => { + // A later group starts a fresh snapshot and compression window. self.group = None; - tracing::warn!( - track = self.track.name(), - group = sequence, - error = ?err, - "snapshot group lost; waiting for a newer one" - ); - break; - } - // The group is still open but has nothing buffered yet. - Poll::Pending => { - group_pending = true; - break; } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err.into())), + Poll::Pending => break, } } - - if let Some(payload) = latest { - return Poll::Ready(Ok(Some(payload))); - } - - // An open group may still deliver frames even after the track finishes (it was appended before - // the finish), so wait on it rather than ending the stream. - if group_pending { - return Poll::Pending; - } - - match track_finished { - true => Poll::Ready(Ok(None)), - false => Poll::Pending, + match latest { + Some(value) => Poll::Ready(Ok(Some(value))), + None => Poll::Pending, } } @@ -123,3 +121,89 @@ impl Consumer { }) } } + +#[cfg(test)] +mod timestamps { + use super::*; + + #[test] + fn exact_timestamps_survive_reads() { + for compression in [crate::Compression::None, crate::Compression::Deflate] { + let scale = moq_net::Timescale::new(90_000).unwrap(); + let track = moq_net::broadcast::Info::new() + .produce() + .create_track("data", moq_net::track::Info::default().with_timescale(scale)) + .unwrap(); + let mut subscriber = track.subscribe(None); + let mut subscription = subscriber.subscription(); + subscription.max_delay = std::time::Duration::MAX; + subscriber.update(subscription).unwrap(); + let config = crate::snapshot::Config { compression }; + let mut producer = crate::snapshot::Producer::new(track, config.clone()); + let mut consumer = Consumer::new(subscriber, config); + let times = [0, 810_001, 990_007]; + for (n, ticks) in times.into_iter().enumerate() { + let at = moq_net::Timestamp::new(ticks, scale).unwrap(); + producer + .update(moq_net::Timed { + value: Bytes::from(vec![n as u8; 32]), + at: Some(at), + }) + .unwrap(); + } + producer.finish().unwrap(); + for (n, ticks) in times.into_iter().enumerate() { + let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&kio::Waiter::noop()) else { + panic!("missing state {n}") + }; + assert_eq!(value.value[0] as u64, n as u64); + let at = value.at.unwrap(); + assert_eq!(at.value(), ticks); + assert_eq!(at.scale(), scale); + } + assert!(matches!( + consumer.poll_next(&kio::Waiter::noop()), + Poll::Ready(Ok(None)) + )); + } + } + + #[test] + fn latest_advances_the_same_cursor_as_next() { + let track = moq_net::broadcast::Info::new() + .produce() + .create_track("data", moq_net::track::Info::default()) + .unwrap(); + let subscriber = track.subscribe(None); + let config = crate::snapshot::Config { + compression: crate::Compression::Deflate, + }; + let mut producer = crate::snapshot::Producer::new(track, config.clone()); + let mut consumer = Consumer::new(subscriber, config); + let waiter = kio::Waiter::noop(); + for n in 0..3 { + producer.update(Bytes::from(vec![n as u8; 32])).unwrap(); + if n == 0 { + let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) else { + panic!("missing first state") + }; + assert_eq!(value.value[0] as u64, 0); + assert_eq!(value.at, None); + } + } + let Poll::Ready(Ok(Some(value))) = consumer.poll_latest(&waiter) else { + panic!("missing latest state") + }; + assert_eq!(value.value[0] as u64, 2); + assert_eq!(value.at, None); + assert!(consumer.poll_next(&waiter).is_pending()); + let n = 3; + producer.update(Bytes::from(vec![n as u8; 32])).unwrap(); + let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) else { + panic!("missing new state") + }; + assert_eq!(value.value[0] as u64, 3); + producer.finish().unwrap(); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(None)))); + } +} diff --git a/rs/moq-flate/src/snapshot/mod.rs b/rs/moq-flate/src/snapshot/mod.rs index 49e0e2571fa..47549204838 100644 --- a/rs/moq-flate/src/snapshot/mod.rs +++ b/rs/moq-flate/src/snapshot/mod.rs @@ -1,10 +1,9 @@ //! Lossy latest-value opaque publishing over [`moq-net`](moq_net) tracks. //! -//! One opaque value updated over time, for consumers that only care about the current state (a -//! poster image, a serialized state blob). This mode is **lossy** by design: a consumer yields only -//! the most recent value. A late joiner (or a consumer that falls behind) jumps straight to the -//! newest group, and older groups are dropped entirely. For an ordered log where every payload is -//! preserved, use [`stream`](crate::stream) instead. +//! One value updated over time. `Consumer::next` yields each state with its frame timestamp, +//! in order within the subscription's drift budget; `Consumer::latest` skips to the newest +//! state. Superseded groups may be lost, so use [`stream`](crate::stream) when every record +//! must survive. A playhead reader sets `max_delay` to its distance behind the live edge. //! //! On the wire each value is one group holding one frame, so a group is self-contained and a //! consumer never needs an older one. With [`Config::compression`] set to @@ -113,12 +112,38 @@ mod test { fn drain(mut consumer: Consumer) -> Vec { let waiter = kio::Waiter::noop(); let mut out = Vec::new(); - while let Poll::Ready(Ok(Some(payload))) = consumer.poll_next(&waiter) { - out.push(payload); + while let Poll::Ready(Ok(Some(payload))) = consumer.poll_latest(&waiter) { + out.push(payload.value); } out } + #[test] + fn next_preserves_intermediate_states() { + let (mut producer, mut track) = producer(true); + let mut subscription = track.subscription(); + subscription.max_delay = std::time::Duration::MAX; + track.update(subscription).unwrap(); + for value in [b"one".as_slice(), b"two", b"three"] { + producer.update(value).unwrap(); + } + producer.finish().unwrap(); + let mut consumer = consume(track, true); + let mut values = Vec::new(); + while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&kio::Waiter::noop()) { + assert_eq!(value.at, None); + values.push(value.value); + } + assert_eq!( + values, + vec![ + Bytes::from_static(b"one"), + Bytes::from_static(b"two"), + Bytes::from_static(b"three") + ] + ); + } + /// A snapshot group the transport can no longer serve -- `Old` when the relay reclaims a /// superseded group, `Evicted` under memory pressure, `Lagged` past the drift budget -- is not /// fatal. A snapshot reader only wants the newest value, so it drops the group and takes the @@ -137,13 +162,13 @@ mod test { group .write_frame(moq_net::Timestamp::now(), Bytes::from_static(b"one")) .unwrap(); - assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(Some(v))) if v == "one")); - assert!(consumer.poll_next(&waiter).is_pending()); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(Some(v))) if v.value == "one")); + assert!(consumer.poll_latest(&waiter).is_pending()); // The relay reclaims the group out from under the reader. group.abort(moq_net::Error::Old).unwrap(); assert!( - consumer.poll_next(&waiter).is_pending(), + consumer.poll_latest(&waiter).is_pending(), "a lost group must not end the reader" ); @@ -152,7 +177,7 @@ mod test { group .write_frame(moq_net::Timestamp::now(), Bytes::from_static(b"two")) .unwrap(); - assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(Some(v))) if v == "two")); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(Some(v))) if v.value == "two")); } /// A stamped payload is written at its capture time, and the returned size is the encoded frame @@ -200,8 +225,8 @@ mod test { for n in 0..3u8 { producer.update(vec![n]).unwrap(); - match consumer.poll_next(&waiter) { - Poll::Ready(Ok(Some(payload))) => assert_eq!(&payload[..], &[n]), + match consumer.poll_latest(&waiter) { + Poll::Ready(Ok(Some(payload))) => assert_eq!(&payload.value[..], &[n]), other => panic!("expected value, got {other:?}"), } } @@ -280,7 +305,7 @@ mod test { let mut consumer = consume(track, false); let waiter = kio::Waiter::noop(); - assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(Some(_))))); - assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(None)))); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(Some(_))))); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(None)))); } } diff --git a/rs/moq-flate/src/snapshot/producer.rs b/rs/moq-flate/src/snapshot/producer.rs index bd59e71727c..b2dcd80f075 100644 --- a/rs/moq-flate/src/snapshot/producer.rs +++ b/rs/moq-flate/src/snapshot/producer.rs @@ -12,7 +12,8 @@ pub use super::Config; /// Publishes an opaque value over a track, one value per group. /// /// Each [`update`](Self::update) rolls a new group holding the whole value, so a consumer only ever -/// needs the newest group and older ones are dropped. For a log where every payload survives, use +/// needs the newest group to reconstruct the value. A reader can retain earlier states within its +/// drift budget. For a log where every payload survives, use /// [`stream`](crate::stream) instead. /// /// Cheaply clonable: clones share one underlying track, like other MoQ producers. diff --git a/rs/moq-flate/src/stream/consumer.rs b/rs/moq-flate/src/stream/consumer.rs index 9ead657026f..e52d48e678e 100644 --- a/rs/moq-flate/src/stream/consumer.rs +++ b/rs/moq-flate/src/stream/consumer.rs @@ -47,13 +47,13 @@ impl Consumer { } } - /// Get the next payload, or `None` once the track ends. - pub async fn next(&mut self) -> Result> { + /// Get the next payload and its exact frame timestamp, or `None` once the track ends. + pub async fn next(&mut self) -> Result>> { kio::wait(|waiter| self.poll_next(waiter)).await } /// Poll for the next payload, without blocking. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { loop { if self.rolled { return Poll::Ready(Err(crate::Error::Rolled)); @@ -78,7 +78,12 @@ impl Consumer { }; match group.poll_read_frame(waiter)? { - Poll::Ready(Some(frame)) => return Poll::Ready(self.decode(&frame.payload).map(Some)), + Poll::Ready(Some(frame)) => { + return Poll::Ready(Ok(Some(moq_net::Timed { + value: self.decode(&frame.payload)?, + at: frame.timestamp, + }))); + } Poll::Ready(None) => { // The log's one group is exhausted. Keep polling the track so a clean end still // reports the log as complete, and so a second group is caught as `Rolled`. @@ -105,3 +110,50 @@ impl Consumer { }) } } + +#[cfg(test)] +mod timestamps { + use super::*; + + #[test] + fn exact_timestamps_survive_reads() { + for compression in [crate::Compression::None, crate::Compression::Deflate] { + let scale = moq_net::Timescale::new(90_000).unwrap(); + let track = moq_net::broadcast::Info::new() + .produce() + .create_track("data", moq_net::track::Info::default().with_timescale(scale)) + .unwrap(); + let mut subscriber = track.subscribe(None); + let mut subscription = subscriber.subscription(); + subscription.max_delay = std::time::Duration::MAX; + subscriber.update(subscription).unwrap(); + let config = crate::stream::Config { compression }; + let mut producer = crate::stream::Producer::new(track, config.clone()); + let mut consumer = Consumer::new(subscriber, config); + let times = [0, 810_001, 990_007]; + for (n, ticks) in times.into_iter().enumerate() { + let at = moq_net::Timestamp::new(ticks, scale).unwrap(); + producer + .append(moq_net::Timed { + value: Bytes::from(vec![n as u8; 32]), + at: Some(at), + }) + .unwrap(); + } + producer.finish().unwrap(); + for (n, ticks) in times.into_iter().enumerate() { + let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&kio::Waiter::noop()) else { + panic!("missing state {n}") + }; + assert_eq!(value.value[0] as u64, n as u64); + let at = value.at.unwrap(); + assert_eq!(at.value(), ticks); + assert_eq!(at.scale(), scale); + } + assert!(matches!( + consumer.poll_next(&kio::Waiter::noop()), + Poll::Ready(Ok(None)) + )); + } + } +} diff --git a/rs/moq-flate/src/stream/mod.rs b/rs/moq-flate/src/stream/mod.rs index 1664ddbb8fb..0eb715f5d8d 100644 --- a/rs/moq-flate/src/stream/mod.rs +++ b/rs/moq-flate/src/stream/mod.rs @@ -138,7 +138,7 @@ mod test { let waiter = kio::Waiter::noop(); let mut out = Vec::new(); while let Poll::Ready(Ok(Some(payload))) = consumer.poll_next(&waiter) { - out.push(payload); + out.push(payload.value); } out } @@ -490,7 +490,7 @@ mod test { assert!(matches!( consumer.poll_next(&waiter), - Poll::Ready(Ok(Some(payload))) if payload == Bytes::from_static(b"first") + Poll::Ready(Ok(Some(payload))) if payload.value == Bytes::from_static(b"first") )); assert!(matches!( consumer.poll_next(&waiter), @@ -532,7 +532,7 @@ mod test { assert!(matches!( consumer.poll_next(&waiter), - Poll::Ready(Ok(Some(payload))) if payload == vec![1u8; 8] + Poll::Ready(Ok(Some(payload))) if payload.value == vec![1u8; 8] )); assert!(matches!( consumer.poll_next(&waiter), diff --git a/rs/moq-json/examples/telemetry.rs b/rs/moq-json/examples/telemetry.rs index 7fdceb8db1c..dbf377d7a33 100644 --- a/rs/moq-json/examples/telemetry.rs +++ b/rs/moq-json/examples/telemetry.rs @@ -131,8 +131,8 @@ fn verify(producer_config: snapshot::Config, ticks: u64) { .unwrap(); // The producer emits exactly one frame per update, so the live consumer yields exactly one // reconstructed value: it must match the input byte-for-byte after decompression + patching. - match consumer.poll_next(&waiter) { - Poll::Ready(Ok(Some(value))) => assert_eq!(value, expected, "tick {tick} reconstruction mismatch"), + match consumer.poll_latest(&waiter) { + Poll::Ready(Ok(Some(value))) => assert_eq!(value.value, expected, "tick {tick} reconstruction mismatch"), other => panic!("tick {tick}: expected a value, got {other:?}"), } } @@ -140,7 +140,7 @@ fn verify(producer_config: snapshot::Config, ticks: u64) { // Drain: nothing left and the stream ends cleanly. assert!( - matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(None))), + matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(None))), "stream did not end cleanly" ); } @@ -174,8 +174,8 @@ fn verify_late_joiner(producer_config: snapshot::Config, ticks: u64) -> usize { let waiter = kio::Waiter::noop(); let mut last = None; let mut yielded = 0; - while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { - last = Some(value); + while let Poll::Ready(Ok(Some(value))) = consumer.poll_latest(&waiter) { + last = Some(value.value); yielded += 1; } assert_eq!( diff --git a/rs/moq-json/src/lib.rs b/rs/moq-json/src/lib.rs index 656f62b114a..b29b46d1f26 100644 --- a/rs/moq-json/src/lib.rs +++ b/rs/moq-json/src/lib.rs @@ -1,7 +1,7 @@ //! JSON publishing over [`moq-net`](moq_net) tracks, in three modes: //! -//! - [`snapshot`]: **lossy**. One JSON value updated over time; a consumer only gets the most -//! recent value. Intermediate updates are collapsed and older groups are dropped. +//! - [`snapshot`]: **lossy**. One JSON value updated over time; `next` yields states inside the +//! drift budget, while `latest` skips to the newest state. Superseded groups may be lost. //! - [`stream`]: **lossless**. An ordered append-log of self-contained records; every record is //! preserved and delivered in order, nothing is ever superseded. //! - [`window`]: **bounded**. An ordered run of records appended to the back and dropped from the diff --git a/rs/moq-json/src/snapshot/consumer.rs b/rs/moq-json/src/snapshot/consumer.rs index 7457b8b947b..5a2651b559c 100644 --- a/rs/moq-json/src/snapshot/consumer.rs +++ b/rs/moq-json/src/snapshot/consumer.rs @@ -49,95 +49,96 @@ impl Consumer { } } - /// Get the next reconstructed value, or `None` once the track ends. - pub async fn next(&mut self) -> Result> + /// Get the next state and its exact frame timestamp, or `None` once the track ends. + pub async fn next(&mut self) -> Result>> where T: Unpin, { kio::wait(|waiter| self.poll_next(waiter)).await } - /// Poll for the next reconstructed value, without blocking. + /// Poll for each state in order, retaining the subscription's drift budget. /// - /// Jumps to the newest group, reads its snapshot, and applies deltas in order. All frames already - /// buffered in the group are applied in one poll but only the resulting *latest* value is yielded: - /// the intermediate reconstructions are stale, so a late joiner (or any consumer that has fallen - /// behind) catches up to the head in a single step instead of replaying every superseded state. - /// Frames must still be decoded in order (the DEFLATE window and merge patches are sequential); - /// only the per-frame deserialize and yield are skipped. Switching to a newer group discards the - /// older one. + /// Finish the current group before taking the next. Lost groups resynchronize from a + /// later snapshot. A timed playhead reader sets its subscription's `max_delay` to its + /// distance behind live; the default zero budget keeps only groups not yet superseded. + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { + self.poll_read(waiter, false) + } + + /// Skip to the newest buffered state and its timestamp, or wait for a new one. + pub async fn latest(&mut self) -> Result>> + where + T: Unpin, + { + kio::wait(|waiter| self.poll_latest(waiter)).await + } + + /// Poll for the newest state, discarding superseded groups and intermediate yields. /// - /// A group the transport can no longer serve is discarded the same way, not reported: on a - /// snapshot track its content is superseded by definition, so the reader waits for the - /// replacement. Only a failure of the track itself ends the stream. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { - // Drain to the newest group, resetting reconstruction state whenever we switch. - let track_finished = loop { - match self.track.poll_next_group(waiter)? { - Poll::Ready(Some(group)) => { - self.group = Some(group); - // The next frame is the new group's snapshot, which also restarts the decoder's window. - self.frames_read = 0; - } - Poll::Ready(None) => break true, - Poll::Pending => break false, + /// Frames inside the newest group are still decoded in order. What this skips is gone; + /// a subsequent `next` resumes after it. An untimed frame keeps `at: None`. + pub fn poll_latest(&mut self, waiter: &kio::Waiter) -> Poll>>> { + self.poll_read(waiter, true) + } + + fn poll_read(&mut self, waiter: &kio::Waiter, skip: bool) -> Poll>>> { + if skip && let Some(latest) = self.track.latest() { + if self.group.as_ref().is_some_and(|group| group.sequence < latest) { + self.group = None; } - }; + if self.group.is_none() { + self.track.set_groups(latest..); + } + } + + let mut advanced = None; + loop { + let Some(group) = &mut self.group else { + match self.track.poll_next_group(waiter)? { + Poll::Ready(Some(group)) => { + self.group = Some(group); + self.frames_read = 0; + continue; + } + Poll::Ready(None) => { + return Poll::Ready(Ok(if let Some(at) = advanced { + self.decoder.decode()?.map(|value| moq_net::Timed { value, at }) + } else { + None + })); + } + Poll::Pending => break, + } + }; - // Apply every frame currently buffered in the group, tracking whether any moved us forward and - // whether the group is still open with nothing buffered yet (vs. exhausted). - // `poll_read_frame` returns an owned `Poll`, so the borrow of `self.group` ends before the - // match arms, leaving `apply` (and clearing the group) free to take `&mut self`. - let mut advanced = false; - let mut group_pending = false; - while let Some(group) = &mut self.group { match group.poll_read_frame(waiter) { Poll::Ready(Ok(Some(frame))) => { self.apply(&frame.payload)?; - advanced = true; + if !skip { + return Poll::Ready(Ok(self.decoder.decode()?.map(|value| moq_net::Timed { + value, + at: frame.timestamp, + }))); + } + advanced = Some(frame.timestamp); } - // The current group is exhausted; wait for a newer one. - Poll::Ready(Ok(None)) => { + Poll::Ready(Ok(None)) => self.group = None, + Poll::Ready(Err( + moq_net::Error::Old + | moq_net::Error::Evicted + | moq_net::Error::Lagged + | moq_net::Error::GroupTooLarge, + )) => { + // A later group starts a fresh snapshot and compression window. self.group = None; - break; - } - // The transport can no longer serve the rest of this group: it was superseded and - // reclaimed (`Old`), dropped under memory pressure (`Evicted`), or read past the - // drift budget (`Lagged`). A snapshot reader only ever wants the newest value, so a - // group whose content is gone is never fatal: drop it and wait for its replacement. - // A track- or session-level failure still arrives through `poll_next_group` above. - Poll::Ready(Err(err)) => { - let sequence = group.sequence; - self.group = None; - tracing::warn!( - track = self.track.name(), - group = sequence, - error = ?err, - "snapshot group lost; waiting for a newer one" - ); - break; - } - // The group is still open but has nothing buffered yet. - Poll::Pending => { - group_pending = true; - break; } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err.into())), + Poll::Pending => break, } } - - if advanced { - // Deserialize once, from the head of the backlog we just drained. - return Poll::Ready(Ok(self.decoder.decode()?)); - } - - // An open group may still deliver frames even after the track finishes (it was appended before - // the finish), so wait on it rather than ending the stream. - if group_pending { - return Poll::Pending; - } - - if track_finished { - Poll::Ready(Ok(None)) + if let Some(at) = advanced { + Poll::Ready(Ok(self.decoder.decode()?.map(|value| moq_net::Timed { value, at }))) } else { Poll::Pending } @@ -153,3 +154,110 @@ impl Consumer { Ok(()) } } + +#[cfg(test)] +mod timestamps { + use super::*; + + #[test] + fn exact_timestamps_survive_reads() { + for compression in [crate::Compression::None, crate::Compression::Deflate] { + let scale = moq_net::Timescale::new(90_000).unwrap(); + let track = moq_net::broadcast::Info::new() + .produce() + .create_track("data", moq_net::track::Info::default().with_timescale(scale)) + .unwrap(); + let mut subscriber = track.subscribe(None); + let mut subscription = subscriber.subscription(); + subscription.max_delay = std::time::Duration::MAX; + subscriber.update(subscription).unwrap(); + let mut config = crate::snapshot::Config::default().with_delta_ratio(100); + config.compression = compression; + let mut producer = crate::snapshot::Producer::new(track, config.clone()); + let mut consumer = Consumer::::new( + subscriber, + crate::snapshot::consumer::Config { + compression, + ..Default::default() + }, + ); + let times = [0, 810_001, 990_007]; + for (n, ticks) in times.into_iter().enumerate() { + let at = moq_net::Timestamp::new(ticks, scale).unwrap(); + producer + .update(moq_net::Timed { + value: &serde_json::json!({"n": n, "padding": "large-repeated-value-for-deltas"}), + at: Some(at), + }) + .unwrap(); + if n == 1 { + producer.cut().unwrap(); + } + } + producer.finish().unwrap(); + for (n, ticks) in times.into_iter().enumerate() { + let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&kio::Waiter::noop()) else { + panic!("missing state {n}") + }; + assert_eq!(value.value["n"].as_u64().unwrap(), n as u64); + let at = value.at.unwrap(); + assert_eq!(at.value(), ticks); + assert_eq!(at.scale(), scale); + } + assert!(matches!( + consumer.poll_next(&kio::Waiter::noop()), + Poll::Ready(Ok(None)) + )); + } + } + + #[test] + fn latest_advances_the_same_cursor_as_next() { + let track = moq_net::broadcast::Info::new() + .produce() + .create_track("data", moq_net::track::Info::default()) + .unwrap(); + let subscriber = track.subscribe(None); + let config = crate::snapshot::Config { + compression: crate::Compression::Deflate, + ..crate::snapshot::Config::default().with_delta_ratio(100) + }; + let mut producer = crate::snapshot::Producer::new(track, config.clone()); + let mut consumer = Consumer::::new( + subscriber, + crate::snapshot::consumer::Config { + compression: crate::Compression::Deflate, + ..Default::default() + }, + ); + let waiter = kio::Waiter::noop(); + for n in 0..3 { + producer + .update(&serde_json::json!({"n": n, "padding": "large-repeated-value-for-deltas"})) + .unwrap(); + if n == 0 { + let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) else { + panic!("missing first state") + }; + assert_eq!(value.value["n"].as_u64().unwrap(), 0); + assert_eq!(value.at, None); + } + } + let Poll::Ready(Ok(Some(value))) = consumer.poll_latest(&waiter) else { + panic!("missing latest state") + }; + assert_eq!(value.value["n"].as_u64().unwrap(), 2); + assert_eq!(value.at, None); + assert!(consumer.poll_next(&waiter).is_pending()); + let n = 3; + producer + .update(&serde_json::json!({"n": n, "padding": "large-repeated-value-for-deltas"})) + .unwrap(); + let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) else { + panic!("missing new state") + }; + assert_eq!(value.value["n"].as_u64().unwrap(), 3); + producer.finish().unwrap(); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(None)))); + } +} diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index 364772b0477..35179407966 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -1,17 +1,15 @@ //! Lossy latest-value JSON publishing over [`moq-net`](moq_net) tracks. //! -//! One JSON value updated over time, for consumers that only care about the current state (a -//! catalog, a status document). This mode is **lossy** by design: a consumer yields only the -//! most recent value. A late joiner (or a consumer that falls behind) jumps straight to the -//! newest group and collapses any buffered backlog into a single yield, and older groups are -//! dropped entirely. Intermediate updates are never replayed. For an ordered log where every -//! record is preserved, use [`stream`](crate::stream) instead. +//! One value updated over time. `Consumer::next` yields each state with its frame timestamp, +//! in order within the subscription's drift budget; `Consumer::latest` skips to the newest +//! state. Superseded groups may be lost, so use [`stream`](crate::stream) when every record +//! must survive. A playhead reader sets `max_delay` to its distance behind the live edge. //! //! On the wire the value is published as a series of groups, where each group is //! self-contained: its first frame is a full snapshot and any following frames are //! [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396.html) JSON Merge Patch deltas applied in -//! order. A consumer jumps to the newest group, reads the snapshot, and applies the deltas, so -//! a late joiner never needs older groups. +//! order. Every group starts independently, so a reader can resynchronize after skipping +//! superseded groups without needing earlier deltas. //! //! Deltas are controlled by [`Config::delta_ratio`]. A ratio of `0` disables them, so every //! change is a fresh snapshot group, matching a plain "one JSON blob per group" track. @@ -206,12 +204,33 @@ mod test { fn drain_with(mut consumer: Consumer) -> Vec { let waiter = kio::Waiter::noop(); let mut out = Vec::new(); - while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { - out.push(value); + while let Poll::Ready(Ok(Some(value))) = consumer.poll_latest(&waiter) { + out.push(value.value); } out } + #[test] + fn next_preserves_intermediate_states() { + let (mut producer, mut track) = producer(cfg(100)); + let mut subscription = track.subscription(); + subscription.max_delay = std::time::Duration::MAX; + track.update(subscription).unwrap(); + for n in [1, 2, 3] { + producer + .update(&json!({ "padding": "same-long-value-so-deltas-stay-in-one-group", "n": n })) + .unwrap(); + } + producer.finish().unwrap(); + let mut consumer = Consumer::::new(track, consumer::Config::default()); + let mut values = Vec::new(); + while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&kio::Waiter::noop()) { + assert_eq!(value.at, None); + values.push(value.value["n"].as_u64().unwrap()); + } + assert_eq!(values, vec![1, 2, 3]); + } + /// A snapshot group the transport can no longer serve -- `Old` when the relay reclaims a /// superseded group, `Evicted` under memory pressure, `Lagged` past the drift budget -- is not /// fatal. A snapshot reader only wants the newest value, so it drops the group and takes the @@ -231,13 +250,13 @@ mod test { group .write_frame(moq_net::Timestamp::now(), Bytes::from_static(br#"{"a":1}"#)) .unwrap(); - assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(Some(v))) if v == json!({ "a": 1 }))); - assert!(consumer.poll_next(&waiter).is_pending()); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(Some(v))) if v.value == json!({ "a": 1 }))); + assert!(consumer.poll_latest(&waiter).is_pending()); // The relay reclaims the group out from under the reader. group.abort(moq_net::Error::Old).unwrap(); assert!( - consumer.poll_next(&waiter).is_pending(), + consumer.poll_latest(&waiter).is_pending(), "a lost group must not end the reader" ); @@ -246,7 +265,7 @@ mod test { group .write_frame(moq_net::Timestamp::now(), Bytes::from_static(br#"{"a":2}"#)) .unwrap(); - assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(Some(v))) if v == json!({ "a": 2 }))); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(Some(v))) if v.value == json!({ "a": 2 }))); } /// Nothing replaces a lost group once the track is finished, so the reader ends cleanly on the @@ -264,11 +283,11 @@ mod test { group .write_frame(moq_net::Timestamp::now(), Bytes::from_static(br#"{"a":1}"#)) .unwrap(); - assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(Some(v))) if v == json!({ "a": 1 }))); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(Some(v))) if v.value == json!({ "a": 1 }))); group.abort(moq_net::Error::Old).unwrap(); track.finish().unwrap(); - assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(None)))); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Ready(Ok(None)))); } #[test] @@ -370,8 +389,8 @@ mod test { for n in 1..=3 { producer.update(&json!({ "a": n })).unwrap(); - match consumer.poll_next(&waiter) { - Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": n })), + match consumer.poll_latest(&waiter) { + Poll::Ready(Ok(Some(value))) => assert_eq!(value.value, json!({ "a": n })), other => panic!("expected value, got {other:?}"), } } @@ -585,8 +604,8 @@ mod test { let mut consumer = Consumer::::new(consumer, consumer::Config::default()); let waiter = kio::Waiter::noop(); let mut last = None; - while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { - last = Some(value); + while let Poll::Ready(Ok(Some(value))) = consumer.poll_latest(&waiter) { + last = Some(value.value); } assert_eq!( last.unwrap(), @@ -630,8 +649,8 @@ mod test { let mut consumer = Consumer::::new(consumer, consumer::Config::default()); let waiter = kio::Waiter::noop(); let mut last = None; - while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { - last = Some(value); + while let Poll::Ready(Ok(Some(value))) = consumer.poll_latest(&waiter) { + last = Some(value.value); } assert_eq!( last.unwrap(), @@ -865,8 +884,8 @@ mod test { let waiter = kio::Waiter::noop(); producer.update(&json!({ "a": 1 })).unwrap(); // snapshot, group 0 - match consumer.poll_next(&waiter) { - Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1 })), + match consumer.poll_latest(&waiter) { + Poll::Ready(Ok(Some(value))) => assert_eq!(value.value, json!({ "a": 1 })), other => panic!("expected first value, got {other:?}"), } @@ -878,8 +897,8 @@ mod test { // The consumer jumps to the newest group and never yields a stale value. let mut last = None; - while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { - last = Some(value); + while let Poll::Ready(Ok(Some(value))) = consumer.poll_latest(&waiter) { + last = Some(value.value); } assert_eq!(last.unwrap(), json!({ "a": 4 })); } @@ -900,7 +919,7 @@ mod test { let waiter = kio::Waiter::noop(); // Track is finished but the open group is empty: pending, not end-of-stream. - assert!(matches!(consumer.poll_next(&waiter), Poll::Pending)); + assert!(matches!(consumer.poll_latest(&waiter), Poll::Pending)); group .write_frame( @@ -910,8 +929,8 @@ mod test { .unwrap(); group.finish().unwrap(); - match consumer.poll_next(&waiter) { - Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1 })), + match consumer.poll_latest(&waiter) { + Poll::Ready(Ok(Some(value))) => assert_eq!(value.value, json!({ "a": 1 })), other => panic!("expected the catalog value, got {other:?}"), } } diff --git a/rs/moq-json/src/stream/consumer.rs b/rs/moq-json/src/stream/consumer.rs index d43f7aa1990..12c0a548924 100644 --- a/rs/moq-json/src/stream/consumer.rs +++ b/rs/moq-json/src/stream/consumer.rs @@ -47,8 +47,8 @@ impl Consumer { } } - /// Get the next record, or `None` once the track ends. - pub async fn next(&mut self) -> Result> + /// Get the next record and its exact frame timestamp, or `None` once the track ends. + pub async fn next(&mut self) -> Result>> where T: Unpin, { @@ -56,7 +56,7 @@ impl Consumer { } /// Poll for the next record, without blocking. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { loop { if self.rolled { return Poll::Ready(Err(crate::Error::Rolled)); @@ -81,7 +81,12 @@ impl Consumer { }; match group.poll_read_frame(waiter)? { - Poll::Ready(Some(frame)) => return Poll::Ready(Ok(Some(self.decoder.decode(&frame.payload)?))), + Poll::Ready(Some(frame)) => { + return Poll::Ready(Ok(Some(moq_net::Timed { + value: self.decoder.decode(&frame.payload)?, + at: frame.timestamp, + }))); + } Poll::Ready(None) => { // The log's one group is exhausted. Keep polling the track so a clean end still // reports the log as complete, and so a second group is caught as `Rolled`. @@ -100,3 +105,50 @@ impl Consumer { } } } + +#[cfg(test)] +mod timestamps { + use super::*; + + #[test] + fn exact_timestamps_survive_reads() { + for compression in [crate::Compression::None, crate::Compression::Deflate] { + let scale = moq_net::Timescale::new(90_000).unwrap(); + let track = moq_net::broadcast::Info::new() + .produce() + .create_track("data", moq_net::track::Info::default().with_timescale(scale)) + .unwrap(); + let mut subscriber = track.subscribe(None); + let mut subscription = subscriber.subscription(); + subscription.max_delay = std::time::Duration::MAX; + subscriber.update(subscription).unwrap(); + let config = crate::stream::Config { compression }; + let mut producer = crate::stream::Producer::new(track, config.clone()); + let mut consumer = Consumer::::new(subscriber, config); + let times = [0, 810_001, 990_007]; + for (n, ticks) in times.into_iter().enumerate() { + let at = moq_net::Timestamp::new(ticks, scale).unwrap(); + producer + .append(moq_net::Timed { + value: &serde_json::json!({"n": n, "padding": "large-repeated-value-for-deltas"}), + at: Some(at), + }) + .unwrap(); + } + producer.finish().unwrap(); + for (n, ticks) in times.into_iter().enumerate() { + let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&kio::Waiter::noop()) else { + panic!("missing state {n}") + }; + assert_eq!(value.value["n"].as_u64().unwrap(), n as u64); + let at = value.at.unwrap(); + assert_eq!(at.value(), ticks); + assert_eq!(at.scale(), scale); + } + assert!(matches!( + consumer.poll_next(&kio::Waiter::noop()), + Poll::Ready(Ok(None)) + )); + } + } +} diff --git a/rs/moq-json/src/stream/mod.rs b/rs/moq-json/src/stream/mod.rs index a4f49ecba00..66155a0e974 100644 --- a/rs/moq-json/src/stream/mod.rs +++ b/rs/moq-json/src/stream/mod.rs @@ -159,7 +159,7 @@ mod test { let waiter = kio::Waiter::noop(); let mut out = Vec::new(); while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { - out.push(value); + out.push(value.value); } out } @@ -231,7 +231,7 @@ mod test { for n in 0..3 { producer.append(&json!({ "n": n })).unwrap(); match consumer.poll_next(&waiter) { - Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "n": n })), + Poll::Ready(Ok(Some(value))) => assert_eq!(value.value, json!({ "n": n })), other => panic!("expected record, got {other:?}"), } } @@ -408,7 +408,7 @@ mod test { assert!(matches!( consumer.poll_next(&waiter), - Poll::Ready(Ok(Some(value))) if value == json!({ "n": 0 }) + Poll::Ready(Ok(Some(value))) if value.value == json!({ "n": 0 }) )); assert!(matches!( consumer.poll_next(&waiter), diff --git a/rs/moq-mux/src/binary.rs b/rs/moq-mux/src/binary.rs index 33e95470dab..d3e15927135 100644 --- a/rs/moq-mux/src/binary.rs +++ b/rs/moq-mux/src/binary.rs @@ -348,20 +348,20 @@ impl Consumer { /// The mode the publisher chose. /// - /// [`Mode::Snapshot`] is lossy: intermediate payloads are superseded and only the newest is - /// yielded. [`Mode::Stream`] preserves every payload. Check this when the application can only + /// [`Mode::Snapshot`] is lossy: groups may be skipped once superseded beyond the subscription + /// drift budget. [`Mode::Stream`] preserves every payload. Check this when the application can only /// work with one of them. pub fn mode(&self) -> &Mode { &self.mode } /// Get the next payload, or `None` once the track ends. - pub async fn next(&mut self) -> crate::Result> { + pub async fn next(&mut self) -> crate::Result>> { kio::wait(|waiter| self.poll_next(waiter)).await } /// Poll for the next payload, without blocking. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> std::task::Poll>> { + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> std::task::Poll>>> { let result = match &mut self.inner { Inner::Snapshot(consumer) => consumer.poll_next(waiter), Inner::Stream(consumer) => consumer.poll_next(waiter), @@ -372,6 +372,19 @@ impl Consumer { std::task::Poll::Pending => std::task::Poll::Pending, } } + + /// Skip to the newest snapshot state; stream mode returns `UnsupportedMode`. + pub async fn latest(&mut self) -> crate::Result>> { + kio::wait(|waiter| self.poll_latest(waiter)).await + } + + /// Poll for the newest snapshot state without discarding records from a lossless stream. + pub fn poll_latest(&mut self, waiter: &kio::Waiter) -> std::task::Poll>>> { + match &mut self.inner { + Inner::Snapshot(consumer) => consumer.poll_latest(waiter).map(|result| result.map_err(Into::into)), + Inner::Stream(_) => std::task::Poll::Ready(Err(crate::Error::UnsupportedMode(self.mode.to_string()))), + } + } } impl crate::catalog::Entry<'_, BinaryConfig> { diff --git a/rs/moq-mux/src/catalog/hang/consumer.rs b/rs/moq-mux/src/catalog/hang/consumer.rs index 0c7465b79e5..519fd5dc6d6 100644 --- a/rs/moq-mux/src/catalog/hang/consumer.rs +++ b/rs/moq-mux/src/catalog/hang/consumer.rs @@ -62,7 +62,7 @@ impl Consumer { /// that from the operator while the missing rendition surfaces much later as a track that /// never fills. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { - let catalog = ready!(self.inner.poll_next(waiter))?; + let catalog = ready!(self.inner.poll_latest(waiter))?.map(|value| value.value); if let Some(catalog) = catalog.as_ref() { catalog.check_renditions()?; check_resolvable(&self.base, catalog)?; diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index 2e65a85154b..e5870822120 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -403,20 +403,20 @@ impl Consumer { /// The mode the publisher chose. /// - /// [`Mode::Snapshot`] is lossy: intermediate values are superseded and only the newest is - /// yielded. [`Mode::Stream`] preserves every record. Check this when the application can only + /// [`Mode::Snapshot`] is lossy: groups may be skipped once superseded beyond the subscription + /// drift budget. [`Mode::Stream`] preserves every record. Check this when the application can only /// work with one of them. pub fn mode(&self) -> &Mode { &self.mode } /// Get the next value, or `None` once the track ends. - pub async fn next(&mut self) -> crate::Result> { + pub async fn next(&mut self) -> crate::Result>> { kio::wait(|waiter| self.poll_next(waiter)).await } /// Poll for the next value, without blocking. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> std::task::Poll>> { + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> std::task::Poll>>> { let result = match &mut self.inner { Inner::Snapshot(consumer) => consumer.poll_next(waiter), Inner::Stream(consumer) => consumer.poll_next(waiter), @@ -427,6 +427,19 @@ impl Consumer { std::task::Poll::Pending => std::task::Poll::Pending, } } + + /// Skip to the newest snapshot state; stream mode returns `UnsupportedMode`. + pub async fn latest(&mut self) -> crate::Result>> { + kio::wait(|waiter| self.poll_latest(waiter)).await + } + + /// Poll for the newest snapshot state without discarding records from a lossless stream. + pub fn poll_latest(&mut self, waiter: &kio::Waiter) -> std::task::Poll>>> { + match &mut self.inner { + Inner::Snapshot(consumer) => consumer.poll_latest(waiter).map(|result| result.map_err(Into::into)), + Inner::Stream(_) => std::task::Poll::Ready(Err(crate::Error::UnsupportedMode(self.mode.to_string()))), + } + } } impl crate::catalog::Entry<'_, JsonConfig> { diff --git a/rs/moq-stats/src/aggregate.rs b/rs/moq-stats/src/aggregate.rs index 01585b950c3..5a7418e00cd 100644 --- a/rs/moq-stats/src/aggregate.rs +++ b/rs/moq-stats/src/aggregate.rs @@ -823,9 +823,9 @@ fn advance( } Poll::Pending => return changed, }, - Reader::Active(reader) => match reader.poll_next(waiter) { + Reader::Active(reader) => match reader.poll_latest(waiter) { Poll::Ready(Ok(Some(frame))) => { - node.observe(frame); + node.observe(frame.value); changed = true; } // The subscription ended: the serving session died (a failover to @@ -1332,11 +1332,11 @@ mod tests { let mut warm = moq_json::snapshot::Consumer::::new(subscriber, Default::default()); let frame = futures::executor::block_on(kio::wait(|waiter| { driver.poll(now, waiter).unwrap(); - warm.poll_next(waiter) + warm.poll_latest(waiter) })) .unwrap() .unwrap(); - assert_eq!(frame["acme/room"].bytes, 30); + assert_eq!(frame.value["acme/room"].bytes, 30); ready.wait(); release.wait(); (new, front, warm, driver) diff --git a/rs/moq-stats/src/consume.rs b/rs/moq-stats/src/consume.rs index 514360b2cf8..a3e592cbea3 100644 --- a/rs/moq-stats/src/consume.rs +++ b/rs/moq-stats/src/consume.rs @@ -107,7 +107,7 @@ pub struct Traffic { impl Traffic { /// The next frame, or `None` once the track ends (the producer went away). pub async fn next(&mut self) -> Result> { - Ok(self.inner.next().await?) + Ok(self.inner.latest().await?.map(|value| value.value)) } } @@ -119,7 +119,7 @@ pub struct Sessions { impl Sessions { /// The next frame, or `None` once the track ends (the producer went away). pub async fn next(&mut self) -> Result> { - Ok(self.inner.next().await?) + Ok(self.inner.latest().await?.map(|value| value.value)) } } @@ -131,7 +131,7 @@ pub struct Totals { impl Totals { /// The next frame, or `None` once the track ends (the producer went away). pub async fn next(&mut self) -> Result> { - Ok(self.inner.next().await?) + Ok(self.inner.latest().await?.map(|value| value.value)) } } @@ -144,7 +144,7 @@ impl Presence { /// The next frame, or `None` once the track ends (the producer went away /// or its group unannounced). pub async fn next(&mut self) -> Result> { - Ok(self.inner.next().await?) + Ok(self.inner.latest().await?.map(|value| value.value)) } } From ee99dc4d8d690cc41ad53e6236b47a0b59c9d485 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 21:46:56 -0700 Subject: [PATCH 03/12] test(data): pin exact latest timestamps and migrate Rust readers The independent Rust caller suite passed 1813 tests with 3 skipped. All-target compile and docs passed; lint exposed lost-group tracing becoming unused, so preserve the existing diagnostics. Final check-test on this source is pending. FFI/C and wrappers still await their public representation/read contract; no claim of full quest completion. Co-Authored-By: GPT-6 --- doc/lib/rs/moq-flate.md | 7 ++++++ doc/lib/rs/moq-json.md | 7 ++++++ doc/setup/upgrade.md | 9 ++++++++ rs/moq-flate/src/snapshot/consumer.rs | 13 +++++++++-- rs/moq-flate/src/snapshot/mod.rs | 2 +- rs/moq-json/src/snapshot/consumer.rs | 32 ++++++++++++++++++++------- rs/moq-json/src/snapshot/mod.rs | 4 ++-- rs/moq-mux/src/binary.rs | 15 +++++++++---- rs/moq-mux/src/json.rs | 19 ++++++++++++---- 9 files changed, 87 insertions(+), 21 deletions(-) diff --git a/doc/lib/rs/moq-flate.md b/doc/lib/rs/moq-flate.md index f0b2853f6a0..7f45aec3f40 100644 --- a/doc/lib/rs/moq-flate.md +++ b/doc/lib/rs/moq-flate.md @@ -32,6 +32,13 @@ an untimed track has no timescale, and a timed track requires a timestamp on every write. A publisher that wants to stamp now samples its own clock. Writes return the encoded frame size. +Consumers return `Timed`: read `.value` for the decoded value and `.at` for +its optional frame timestamp, with the track's exact scale preserved. Snapshot +`next()` yields states in order inside the subscription's `max_delay` budget; +set that budget when following a playhead behind live. Use `latest()` to skip +to the newest buffered state. Skipped states are gone, and a later `next()` +continues after them. + When upgrading, replace `Timed` with `Timed

` and pass capture times explicitly instead of relying on a write-time default. diff --git a/doc/lib/rs/moq-json.md b/doc/lib/rs/moq-json.md index fb0dafc3334..57674813662 100644 --- a/doc/lib/rs/moq-json.md +++ b/doc/lib/rs/moq-json.md @@ -23,6 +23,13 @@ The timestamp must match the track: an untimed track has no timescale, and a timed track requires a timestamp on every write. Editing guards take the same optional capture time when opened, including when they publish on drop. +Consumers return `Timed`: read `.value` for the decoded value and `.at` for +its optional frame timestamp, with the track's exact scale preserved. Snapshot +`next()` yields states in order inside the subscription's `max_delay` budget; +set that budget when following a playhead behind live. Use `latest()` to skip +to the newest buffered state. Skipped states are gone, and a later `next()` +continues after them. + When upgrading, replace `Timed` with `Timed

` and pass timestamps explicitly. A publisher that wants to stamp an event now samples its own clock; the producer never supplies one. diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index dd5c605fdc7..c839daf1bdc 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -117,6 +117,15 @@ These land with the next breaking release, not the 2026-09-23 train. now carries `moq_net::Error`, so it is no longer `PartialEq`. moq-mux's `Error::Binary` is `Error::Flate`. In TypeScript, import `Snapshot` and `Stream` from `@moq/flate`. +- **Rust data consumers return `Timed`.** `moq_json` and `moq_flate` snapshot + and stream `next`/`poll_next`, and the moq-mux JSON/binary wrappers, now return + `.value` plus optional `.at`, preserving the frame's exact timestamp and scale. + An untimed frame keeps `None`; a timestamp of zero remains present. Snapshot + `next` yields each state inside the subscription's drift budget. Use `latest` + (or `poll_latest`) for the previous collapse-to-newest behavior, and set + `max_delay` when following a playhead behind live. The mux wrappers refuse + `latest` on a stream rather than discard lossless records. Track framing is + unchanged. - **moq-ffi data tracks wrap a track.** The broadcast's `publish_binary_*`, `publish_json_*`, and `subscribe_json_*` are gone. Create the track with `publish_track` or `subscribe_track`, then construct diff --git a/rs/moq-flate/src/snapshot/consumer.rs b/rs/moq-flate/src/snapshot/consumer.rs index 7b4604fc32b..e5d87a35636 100644 --- a/rs/moq-flate/src/snapshot/consumer.rs +++ b/rs/moq-flate/src/snapshot/consumer.rs @@ -95,12 +95,14 @@ impl Consumer { } Poll::Ready(Ok(None)) => self.group = None, Poll::Ready(Err( - moq_net::Error::Old + err @ (moq_net::Error::Old | moq_net::Error::Evicted | moq_net::Error::Lagged - | moq_net::Error::GroupTooLarge, + | moq_net::Error::GroupTooLarge), )) => { // A later group starts a fresh snapshot and compression window. + tracing::warn!(track = self.track.name(), group = group.sequence, error = ?err, + "snapshot group lost; waiting for a newer one"); self.group = None; } Poll::Ready(Err(err)) => return Poll::Ready(Err(err.into())), @@ -141,6 +143,7 @@ mod timestamps { let config = crate::snapshot::Config { compression }; let mut producer = crate::snapshot::Producer::new(track, config.clone()); let mut consumer = Consumer::new(subscriber, config); + let mut newest = Consumer::new(producer.consume(), crate::snapshot::Config { compression }); let times = [0, 810_001, 990_007]; for (n, ticks) in times.into_iter().enumerate() { let at = moq_net::Timestamp::new(ticks, scale).unwrap(); @@ -165,6 +168,12 @@ mod timestamps { consumer.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)) )); + let Poll::Ready(Ok(Some(value))) = newest.poll_latest(&kio::Waiter::noop()) else { + panic!("missing latest state") + }; + assert_eq!(value.value[0] as u64, 2); + assert_eq!(value.at.unwrap().value(), 990_007); + assert_eq!(value.at.unwrap().scale(), scale); } } diff --git a/rs/moq-flate/src/snapshot/mod.rs b/rs/moq-flate/src/snapshot/mod.rs index 47549204838..4ac2627b10f 100644 --- a/rs/moq-flate/src/snapshot/mod.rs +++ b/rs/moq-flate/src/snapshot/mod.rs @@ -108,7 +108,7 @@ mod test { Consumer::new(track, cfg(compression)) } - /// Drain every value currently available without blocking. + /// Drain newest states currently available without blocking. fn drain(mut consumer: Consumer) -> Vec { let waiter = kio::Waiter::noop(); let mut out = Vec::new(); diff --git a/rs/moq-json/src/snapshot/consumer.rs b/rs/moq-json/src/snapshot/consumer.rs index 5a2651b559c..0457e9619fb 100644 --- a/rs/moq-json/src/snapshot/consumer.rs +++ b/rs/moq-json/src/snapshot/consumer.rs @@ -26,7 +26,8 @@ pub struct Config { /// Consumes a JSON value from a track, reconstructing it from snapshots and deltas. /// /// A [`Decoder`] that owns its track: it reads groups, routes each frame by its position, and -/// yields the reconstructed value. When something else already owns the track, use the [`Decoder`] +/// yields each reconstructed state and its frame timestamp, or skips to the newest with `latest`. +/// When something else already owns the track, use the [`Decoder`] /// directly. pub struct Consumer { track: moq_net::track::Ordered, @@ -93,6 +94,7 @@ impl Consumer { } let mut advanced = None; + let mut finished = false; loop { let Some(group) = &mut self.group else { match self.track.poll_next_group(waiter)? { @@ -102,11 +104,8 @@ impl Consumer { continue; } Poll::Ready(None) => { - return Poll::Ready(Ok(if let Some(at) = advanced { - self.decoder.decode()?.map(|value| moq_net::Timed { value, at }) - } else { - None - })); + finished = true; + break; } Poll::Pending => break, } @@ -125,12 +124,14 @@ impl Consumer { } Poll::Ready(Ok(None)) => self.group = None, Poll::Ready(Err( - moq_net::Error::Old + err @ (moq_net::Error::Old | moq_net::Error::Evicted | moq_net::Error::Lagged - | moq_net::Error::GroupTooLarge, + | moq_net::Error::GroupTooLarge), )) => { // A later group starts a fresh snapshot and compression window. + tracing::warn!(track = self.track.name(), group = group.sequence, error = ?err, + "snapshot group lost; waiting for a newer one"); self.group = None; } Poll::Ready(Err(err)) => return Poll::Ready(Err(err.into())), @@ -139,6 +140,8 @@ impl Consumer { } if let Some(at) = advanced { Poll::Ready(Ok(self.decoder.decode()?.map(|value| moq_net::Timed { value, at }))) + } else if finished { + Poll::Ready(Ok(None)) } else { Poll::Pending } @@ -181,6 +184,13 @@ mod timestamps { ..Default::default() }, ); + let mut newest = Consumer::::new( + producer.consume(), + crate::snapshot::consumer::Config { + compression, + ..Default::default() + }, + ); let times = [0, 810_001, 990_007]; for (n, ticks) in times.into_iter().enumerate() { let at = moq_net::Timestamp::new(ticks, scale).unwrap(); @@ -208,6 +218,12 @@ mod timestamps { consumer.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)) )); + let Poll::Ready(Ok(Some(value))) = newest.poll_latest(&kio::Waiter::noop()) else { + panic!("missing latest state") + }; + assert_eq!(value.value["n"].as_u64().unwrap(), 2); + assert_eq!(value.at.unwrap().value(), 990_007); + assert_eq!(value.at.unwrap().scale(), scale); } } diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index 35179407966..1a8c69700a8 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -195,12 +195,12 @@ mod test { assert!(demand.poll_unused(&waiter).is_pending()); } - /// Drain every value currently available from a plaintext consumer without blocking. + /// Drain newest states currently available from a plaintext consumer without blocking. fn drain(track: moq_net::track::Subscriber) -> Vec { drain_with(Consumer::::new(track, consumer::Config::default())) } - /// Drain every value currently available from an already-built consumer without blocking. + /// Drain newest states currently available from an already-built consumer without blocking. fn drain_with(mut consumer: Consumer) -> Vec { let waiter = kio::Waiter::noop(); let mut out = Vec::new(); diff --git a/rs/moq-mux/src/binary.rs b/rs/moq-mux/src/binary.rs index d3e15927135..35342fc83b5 100644 --- a/rs/moq-mux/src/binary.rs +++ b/rs/moq-mux/src/binary.rs @@ -456,8 +456,12 @@ mod test { fn drain(mut consumer: Consumer) -> Vec { let waiter = kio::Waiter::noop(); let mut out = Vec::new(); - while let Poll::Ready(Ok(Some(payload))) = consumer.poll_next(&waiter) { - out.push(payload); + while let Poll::Ready(Ok(Some(payload))) = if consumer.mode() == &Mode::Snapshot { + consumer.poll_latest(&waiter) + } else { + consumer.poll_next(&waiter) + } { + out.push(payload.value); } out } @@ -869,8 +873,11 @@ mod test { .await .unwrap(); telemetry.finish().unwrap(); - assert_eq!(reader.next().await.unwrap(), Some(Bytes::from_static(b"heartbeat"))); - assert_eq!(reader.next().await.unwrap(), None); + assert_eq!( + reader.next().await.unwrap().map(|value| value.value), + Some(Bytes::from_static(b"heartbeat")) + ); + assert_eq!(reader.next().await.unwrap().map(|value| value.value), None); } #[test] diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index e5870822120..756d2c3a557 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -501,8 +501,12 @@ mod test { fn drain(mut consumer: Consumer) -> Vec { let waiter = kio::Waiter::noop(); let mut out = Vec::new(); - while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { - out.push(value); + while let Poll::Ready(Ok(Some(value))) = if consumer.mode() == &Mode::Snapshot { + consumer.poll_latest(&waiter) + } else { + consumer.poll_next(&waiter) + } { + out.push(value.value); } out } @@ -807,8 +811,15 @@ mod test { let mut consumer = entry.subscribe::(&source).await.unwrap(); chat.finish().unwrap(); - assert_eq!(consumer.next().await.unwrap(), Some(json!({ "text": "hello" }))); - assert_eq!(consumer.next().await.unwrap(), None); + assert!(matches!( + consumer.poll_latest(&kio::Waiter::noop()), + Poll::Ready(Err(crate::Error::UnsupportedMode(_))) + )); + assert_eq!( + consumer.next().await.unwrap().map(|value| value.value), + Some(json!({ "text": "hello" })) + ); + assert_eq!(consumer.next().await.unwrap().map(|value| value.value), None); } /// A value that fails to serialize never reaches the track, but the log is missing it all the From 8db2cf4af61981c048cb3ac4d1ba7e732ccf6859 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 14:48:09 -0700 Subject: [PATCH 04/12] feat(ffi)!: return JSON values with their frame timestamps moq-ffi JSON consumers return MoqJsonValue (value plus timestamp_us) and the snapshot consumer gains latest(). C's moq_json_value carries the timestamp, and the Python, Go, Swift, Kotlin, Dart and C++ wrappers expose it. Fix the teleop example, added on main, for the Timed reads. Co-Authored-By: Claude Opus 5.5 --- cpp/moq/include/moq/moq.hpp | 1 + dart/moq/lib/json.dart | 7 +- dart/moq/test/moq_test.dart | 21 ++- dart/moq_ffi/lib/src/moq.dart | 152 ++++++++++++++++-- doc/lib/go/index.md | 2 + doc/lib/index.md | 2 +- doc/setup/upgrade.md | 10 ++ go/wrapper/json/json.go | 55 +++++-- go/wrapper/json/json_test.go | 51 ++++-- .../kotlin/dev/moq/json/Tracks.kt | 40 +++-- .../kotlin/dev/moq/SmokeTest.kt | 18 ++- py/moq-rs/docs/index.md | 1 + py/moq-rs/moq/json.py | 57 +++++-- py/moq-rs/tests/test_local.py | 19 ++- rs/moq-c/src/api.rs | 13 +- rs/moq-c/src/consume.rs | 39 +++-- rs/moq-c/src/test.rs | 14 ++ rs/moq-ffi/src/json.rs | 69 ++++---- rs/moq-ffi/src/test.rs | 45 ++++-- rs/moq-json/examples/teleop.rs | 17 +- swift/Sources/Moq/Json.swift | 57 +++++-- swift/Tests/MoqTests/SmokeTests.swift | 21 ++- 22 files changed, 543 insertions(+), 168 deletions(-) diff --git a/cpp/moq/include/moq/moq.hpp b/cpp/moq/include/moq/moq.hpp index 67a403ee551..8f1d2a0a71a 100644 --- a/cpp/moq/include/moq/moq.hpp +++ b/cpp/moq/include/moq/moq.hpp @@ -115,6 +115,7 @@ using FlateConfig = MoqFlateConfig; using Frame = MoqFrame; using JsonSnapshotConfig = MoqJsonSnapshotConfig; using JsonStreamConfig = MoqJsonStreamConfig; +using JsonValue = MoqJsonValue; using MediaTrackConfig = MoqMediaTrackConfig; using MediaGroupConfig = MoqMediaGroupConfig; using MediaFrame = MoqMediaFrame; diff --git a/dart/moq/lib/json.dart b/dart/moq/lib/json.dart index 539fb5f6340..bf053621b65 100644 --- a/dart/moq/lib/json.dart +++ b/dart/moq/lib/json.dart @@ -21,7 +21,8 @@ import 'package:moq_ffi/moq_ffi.dart'; /// Publishes lossy latest-value JSON snapshots on a track it takes over. typedef SnapshotProducer = MoqJsonSnapshotProducer; -/// Consumes reconstructed latest-value JSON snapshots from a track it takes over. +/// Consumes each JSON snapshot state from a track it takes over, or skips to +/// the newest with `latest()`. typedef SnapshotConsumer = MoqJsonSnapshotConsumer; /// Configures a lossy latest-value JSON track. @@ -35,3 +36,7 @@ typedef StreamConsumer = MoqJsonStreamConsumer; /// Configures a lossless JSON stream track. typedef StreamConfig = MoqJsonStreamConfig; + +/// A JSON string read from a track, with the timestamp of the frame that +/// carried it in microseconds, or null when untimed. +typedef Value = MoqJsonValue; diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 8793cfc4d29..1ac36785cb9 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -370,7 +370,10 @@ void main() { final broadcast = MoqBroadcastProducer(); final producer = moq_json.SnapshotProducer( broadcast: broadcast, - track: broadcast.publishTrack(name: 'status', info: null), + track: broadcast.publishTrack( + name: 'status', + info: MoqTrackInfo(timescale: 1000000), + ), config: moq_json.SnapshotConfig(), ); final track = await broadcast @@ -382,9 +385,19 @@ void main() { config: moq_json.SnapshotConfig(), ); - producer.update(value: jsonEncode({'state': 'live'})); - final value = await consumer.next().timeout(timeout); - expect(jsonDecode(value!), {'state': 'live'}); + for (var n = 1; n <= 4; n++) { + producer.update(value: jsonEncode({'n': n}), timestampUs: n * 1000); + } + + // next() yields every state in order; latest() skips the buffered third. + moq_json.Value? value = await consumer.next().timeout(timeout); + expect(jsonDecode(value!.value), {'n': 1}); + expect(value.timestampUs, 1000); + value = await consumer.next().timeout(timeout); + expect(jsonDecode(value!.value), {'n': 2}); + value = await consumer.latest().timeout(timeout); + expect(jsonDecode(value!.value), {'n': 4}); + expect(value.timestampUs, 4000); consumer.cancel(); producer.finish(); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 3f2a0773313..81a65520ad4 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,7 +8,9 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; + import "package:ffi/ffi.dart"; + import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -404,6 +406,65 @@ class FfiConverterMoqJsonStreamConfig { } } +class MoqJsonValue { + final String value; + final int? timestampUs; + MoqJsonValue({required this.value, this.timestampUs}); +} + +class FfiConverterMoqJsonValue { + static MoqJsonValue lift(RustBuffer buf) { + return FfiConverterMoqJsonValue.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final value_lifted = FfiConverterString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final value = value_lifted.value; + new_offset += value_lifted.bytesRead; + final timestampUs_lifted = FfiConverterOptionalUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final timestampUs = timestampUs_lifted.value; + new_offset += timestampUs_lifted.bytesRead; + return LiftRetVal( + MoqJsonValue(value: value, timestampUs: timestampUs), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqJsonValue value) { + final total_length = + FfiConverterString.allocationSize(value.value) + + FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqJsonValue value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterString.write( + value.value, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalUInt64.write( + value.timestampUs, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqJsonValue value) { + return FfiConverterString.allocationSize(value.value) + + FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + + 0; + } +} + class MoqAudio { final String? label; final String? broadcast; @@ -6127,7 +6188,8 @@ class FfiConverterMoqFlateStreamProducer { abstract class MoqJsonSnapshotConsumerInterface { void cancel(); - Future next(); + Future latest(); + Future next(); } final _MoqJsonSnapshotConsumerFinalizer = Finalizer>((ptr) { @@ -6187,7 +6249,20 @@ class MoqJsonSnapshotConsumer implements MoqJsonSnapshotConsumerInterface { }, null); } - Future next() { + Future latest() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqjsonsnapshotconsumer_latest( + uniffiClonePointer(), + ), + ffi_moq_ffi_rust_future_poll_rust_buffer, + ffi_moq_ffi_rust_future_complete_rust_buffer, + ffi_moq_ffi_rust_future_free_rust_buffer, + FfiConverterOptionalMoqJsonValue.lift, + moqExceptionErrorHandler, + ); + } + + Future next() { return uniffiRustCallAsync( () => uniffi_moq_ffi_fn_method_moqjsonsnapshotconsumer_next( uniffiClonePointer(), @@ -6195,7 +6270,7 @@ class MoqJsonSnapshotConsumer implements MoqJsonSnapshotConsumerInterface { ffi_moq_ffi_rust_future_poll_rust_buffer, ffi_moq_ffi_rust_future_complete_rust_buffer, ffi_moq_ffi_rust_future_free_rust_buffer, - FfiConverterOptionalString.lift, + FfiConverterOptionalMoqJsonValue.lift, moqExceptionErrorHandler, ); } @@ -6343,7 +6418,7 @@ class FfiConverterMoqJsonSnapshotProducer { abstract class MoqJsonStreamConsumerInterface { void cancel(); - Future next(); + Future next(); } final _MoqJsonStreamConsumerFinalizer = Finalizer>((ptr) { @@ -6403,7 +6478,7 @@ class MoqJsonStreamConsumer implements MoqJsonStreamConsumerInterface { }, null); } - Future next() { + Future next() { return uniffiRustCallAsync( () => uniffi_moq_ffi_fn_method_moqjsonstreamconsumer_next( uniffiClonePointer(), @@ -6411,7 +6486,7 @@ class MoqJsonStreamConsumer implements MoqJsonStreamConsumerInterface { ffi_moq_ffi_rust_future_poll_rust_buffer, ffi_moq_ffi_rust_future_complete_rust_buffer, ffi_moq_ffi_rust_future_free_rust_buffer, - FfiConverterOptionalString.lift, + FfiConverterOptionalMoqJsonValue.lift, moqExceptionErrorHandler, ); } @@ -10203,6 +10278,53 @@ class FfiConverterOptionalMoqGroupConsumer { } } +class FfiConverterOptionalMoqJsonValue { + static MoqJsonValue? lift(RustBuffer buf) { + return FfiConverterOptionalMoqJsonValue.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) { + return LiftRetVal(null, 1); + } + final result = FfiConverterMoqJsonValue.read( + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ); + return LiftRetVal(result.value, result.bytesRead + 1); + } + + static int allocationSize([MoqJsonValue? value]) { + if (value == null) { + return 1; + } + return FfiConverterMoqJsonValue.allocationSize(value) + 1; + } + + static RustBuffer lower(MoqJsonValue? value) { + if (value == null) { + return toRustBuffer(Uint8List.fromList([0])); + } + final buf = Uint8List( + FfiConverterOptionalMoqJsonValue.allocationSize(value), + ); + FfiConverterOptionalMoqJsonValue.write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqJsonValue? value, Uint8List buf) { + if (value == null) { + buf[0] = 0; + return 1; + } + buf[0] = 1; + return FfiConverterMoqJsonValue.write( + value, + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ) + + 1; + } +} + class FfiConverterOptionalMoqMediaFrame { static MoqMediaFrame? lift(RustBuffer buf) { return FfiConverterOptionalMoqMediaFrame.read(buf.asUint8List()).value; @@ -11419,6 +11541,11 @@ external void uniffi_moq_ffi_fn_method_moqjsonsnapshotconsumer_cancel( Pointer uniffiStatus, ); +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqjsonsnapshotconsumer_latest( + Pointer ptr, +); + @Native Function(Pointer)>(assetId: _uniffiAssetId) external Pointer uniffi_moq_ffi_fn_method_moqjsonsnapshotconsumer_next( Pointer ptr, @@ -13402,6 +13529,9 @@ external int uniffi_moq_ffi_checksum_method_moqflatestreamproducer_finish(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_cancel(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_latest(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_next(); @@ -13924,10 +14054,14 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_cancel() != - 45114) { + 52062) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_latest() != + 60932) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_next() != 64727) { + if (uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_next() != 65302) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqjsonsnapshotproducer_demand() != @@ -13945,7 +14079,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqjsonstreamconsumer_cancel() != 29308) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqjsonstreamconsumer_next() != 7523) { + if (uniffi_moq_ffi_checksum_method_moqjsonstreamconsumer_next() != 21257) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqjsonstreamproducer_append() != 36078) { diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 0feadd55778..be2eff9896d 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -21,6 +21,8 @@ A missing data timestamp stays untimed; the library never substitutes zero or no Raw tracks with a `nil` timescale are untimed on both publish and receive. `moq.dev/moq/flate` publishes opaque snapshot and stream payloads; its `Update` / `Append` and the JSON producers take a final `*time.Duration` timestamp argument (`nil` for untimed). +JSON consumers return a `*Timed` holding the value and that timestamp; `SnapshotConsumer.Latest` +skips to the newest state where `Next` yields each one. Whole-frame media writes require caller time; container imports keep their own timeline handling. Timed raw/data tracks require caller-supplied timestamps. Use `CatalogProducer.Now()` to stamp a locally created event on the broadcast clock; reading it fixes that clock for later media producers. diff --git a/doc/lib/index.md b/doc/lib/index.md index 12f6a28ca11..95092890b33 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -39,7 +39,7 @@ how it looks in that language: - **Discover** broadcasts by prefix, wait for a specific one, or request one by path. Advertise an exact path (a created broadcast is invisible until announced), or claim a prefix and serve requests beneath it on demand. - **Publish and subscribe to media** with the hang catalog filled in from the bitstream, or hand over raw pixels and PCM and let the binding encode and decode (VideoToolbox, Media Foundation, NVENC, openh264, Opus). The built-in video encoder can follow the connection's send estimate. - **Raw tracks** of arbitrary bytes, with per-subscriber priority and max delay, and best-effort datagrams. -- **JSON tracks** as a latest value with merge-patch deltas, or as an append log. +- **JSON tracks** as a latest value with merge-patch deltas, or as an append log. Each value reads back with its frame timestamp, and a snapshot reader either replays every state or skips to the newest. - **Fetch** a single cached group by sequence. - **Catalog extensions**: write your own section next to `video` and `audio`, and read others' back. - **Routes**: see which relays a broadcast came through, and advertise a cost as a standby publisher. diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index 605a03aa03e..ec1e84c5814 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -152,6 +152,16 @@ These land with the next breaking release, not the 2026-09-23 train. `max_delay` when following a playhead behind live. The mux wrappers refuse `latest` on a stream rather than discard lossless records. Track framing is unchanged. +- **JSON consumers in every binding return the frame timestamp.** moq-ffi's + `MoqJsonSnapshotConsumer` and `MoqJsonStreamConsumer` `next()` return a + `MoqJsonValue` (`value` plus `timestamp_us`, null when untimed) instead of a + string, and the snapshot consumer adds `latest()`. Its `next()` now yields each + state in order; call `latest()` for the old collapse-to-newest read. Python + and Go consumers yield `json.Timed` (`value`, `timestamp` as a duration), Swift + yields `Json.Timed` (`value`, `timestampUs`), Kotlin's `values()` emits `Value` + and `valuesAs()` emits `Timed`, and Dart and C++ return `Value` / + `JsonValue`. C's `moq_json_value` gains `timestamp_us` and `timestamp_present`, + and a snapshot callback now fires for each state rather than only the newest. - **moq-ffi data tracks wrap a track.** The broadcast's `publish_binary_*`, `publish_json_*`, and `subscribe_json_*` are gone. Create the track with `publish_track` or `subscribe_track`, then construct diff --git a/go/wrapper/json/json.go b/go/wrapper/json/json.go index 71833c6b86e..20121e659e8 100644 --- a/go/wrapper/json/json.go +++ b/go/wrapper/json/json.go @@ -152,7 +152,27 @@ func (p *StreamProducer) Finish() error { return p.inner.Finish() } -// SnapshotConsumer receives the latest reconstructed JSON value. +// Timed is a JSON value and the timestamp of the frame that carried it. +type Timed struct { + // Value is the JSON document. + Value stdjson.RawMessage + // Timestamp is on the broadcast clock, or nil on an untimed track. + Timestamp *time.Duration +} + +func timed(value *ffi.MoqJsonValue) *Timed { + if value == nil { + return nil + } + out := &Timed{Value: stdjson.RawMessage(value.Value)} + if value.TimestampUs != nil { + at := time.Duration(*value.TimestampUs) * time.Microsecond + out.Timestamp = &at + } + return out +} + +// SnapshotConsumer receives each reconstructed JSON state. type SnapshotConsumer struct { inner *ffi.MoqJsonSnapshotConsumer } @@ -171,18 +191,23 @@ func NewSnapshotConsumer(track *moq.TrackConsumer, options ConsumerOptions) (*Sn return &SnapshotConsumer{inner: inner}, nil } -// Next returns the next JSON value, or nil when the track ends. -func (c *SnapshotConsumer) Next(ctx context.Context) (*stdjson.RawMessage, error) { +// Next returns the next state in order, or nil when the track ends. Groups older +// than the subscription's MaxDelay are skipped, so a playhead reader sets that +// budget to its distance behind live. +func (c *SnapshotConsumer) Next(ctx context.Context) (*Timed, error) { value, err := bridge.Call(ctx, c.inner.Cancel, c.inner.Next) - if err != nil || value == nil { - return nil, err - } - decoded := stdjson.RawMessage(*value) - return &decoded, nil + return timed(value), err +} + +// Latest skips to the newest state, or waits for one; nil when the track ends. +// Skipped states are gone, and Next continues after this one. +func (c *SnapshotConsumer) Latest(ctx context.Context) (*Timed, error) { + value, err := bridge.Call(ctx, c.inner.Cancel, c.inner.Latest) + return timed(value), err } -// All ranges over reconstructed JSON values until the track ends. -func (c *SnapshotConsumer) All(ctx context.Context) iter.Seq2[*stdjson.RawMessage, error] { +// All ranges over each JSON state until the track ends. +func (c *SnapshotConsumer) All(ctx context.Context) iter.Seq2[*Timed, error] { return bridge.Seq(ctx, c.Next) } @@ -210,17 +235,13 @@ func NewStreamConsumer(track *moq.TrackConsumer, options ConsumerOptions) (*Stre } // Next returns the next JSON record, or nil when the track ends. -func (c *StreamConsumer) Next(ctx context.Context) (*stdjson.RawMessage, error) { +func (c *StreamConsumer) Next(ctx context.Context) (*Timed, error) { value, err := bridge.Call(ctx, c.inner.Cancel, c.inner.Next) - if err != nil || value == nil { - return nil, err - } - decoded := stdjson.RawMessage(*value) - return &decoded, nil + return timed(value), err } // All ranges over every JSON record until the track ends. -func (c *StreamConsumer) All(ctx context.Context) iter.Seq2[*stdjson.RawMessage, error] { +func (c *StreamConsumer) All(ctx context.Context) iter.Seq2[*Timed, error] { return bridge.Seq(ctx, c.Next) } diff --git a/go/wrapper/json/json_test.go b/go/wrapper/json/json_test.go index f5b62f6c9ce..fa4db09a90b 100644 --- a/go/wrapper/json/json_test.go +++ b/go/wrapper/json/json_test.go @@ -54,29 +54,50 @@ func TestTracks(t *testing.T) { defer cancel() broadcast, consumer := newBroadcast(t) - snapshot, err := moqjson.NewSnapshotProducer(broadcast, publishTrack(t, broadcast, "status"), moqjson.SnapshotOptions{Compression: true}) + scale := uint64(1_000_000) + track, err := broadcast.PublishTrack("status", &moq.TrackInfo{Priority: 127, Timescale: &scale}) if err != nil { t.Fatal(err) } - snapshotConsumer, err := moqjson.NewSnapshotConsumer(subscribeTrack(ctx, t, consumer, "status"), moqjson.ConsumerOptions{Compression: true}) + snapshot, err := moqjson.NewSnapshotProducer(broadcast, track, moqjson.SnapshotOptions{Compression: true}) if err != nil { t.Fatal(err) } - defer snapshotConsumer.Cancel() - if err := snapshot.Update(map[string]any{"viewers": 42}, nil); err != nil { - t.Fatal(err) - } - value, err := snapshotConsumer.Next(ctx) + snapshotConsumer, err := moqjson.NewSnapshotConsumer(subscribeTrack(ctx, t, consumer, "status"), moqjson.ConsumerOptions{Compression: true}) if err != nil { t.Fatal(err) } - var decoded map[string]any - if err := json.Unmarshal(*value, &decoded); err != nil { - t.Fatal(err) - } - if decoded["viewers"] != float64(42) { - t.Fatalf("snapshot = %s", *value) + defer snapshotConsumer.Cancel() + for viewers := 1; viewers <= 4; viewers++ { + at := time.Duration(viewers) * time.Millisecond + if err := snapshot.Update(map[string]any{"viewers": viewers}, &at); err != nil { + t.Fatal(err) + } + } + + // Next yields every state in order; Latest skips the buffered third. + expect := func(value *moqjson.Timed, err error, viewers int) { + t.Helper() + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(value.Value, &decoded); err != nil { + t.Fatal(err) + } + if decoded["viewers"] != float64(viewers) { + t.Fatalf("snapshot = %s, want viewers %d", value.Value, viewers) + } + if want := time.Duration(viewers) * time.Millisecond; value.Timestamp == nil || *value.Timestamp != want { + t.Fatalf("timestamp = %v, want %v", value.Timestamp, want) + } } + value, err := snapshotConsumer.Next(ctx) + expect(value, err, 1) + value, err = snapshotConsumer.Next(ctx) + expect(value, err, 2) + value, err = snapshotConsumer.Latest(ctx) + expect(value, err, 4) stream, err := moqjson.NewStreamProducer(broadcast, publishTrack(t, broadcast, "events"), moqjson.StreamOptions{Compression: true}) if err != nil { @@ -94,8 +115,8 @@ func TestTracks(t *testing.T) { if err != nil { t.Fatal(err) } - if string(*record) != `{"n":1}` { - t.Fatalf("record = %s", *record) + if string(record.Value) != `{"n":1}` || record.Timestamp != nil { + t.Fatalf("record = %s at %v, want untimed", record.Value, record.Timestamp) } if err := snapshot.Finish(); err != nil { diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt index 7970a86931f..cdfa8de663f 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.serialization.serializer +import kotlin.time.Duration +import kotlin.time.Duration.Companion.microseconds /** Publishes lossy latest-value JSON snapshots on a track it takes over. */ typealias SnapshotProducer = uniffi.moq.MoqJsonSnapshotProducer @@ -30,6 +32,19 @@ typealias StreamConsumer = uniffi.moq.MoqJsonStreamConsumer typealias SnapshotConfig = uniffi.moq.MoqJsonSnapshotConfig /** Configures a lossless JSON stream track. */ typealias StreamConfig = uniffi.moq.MoqJsonStreamConfig +/** A JSON string and the timestamp of the frame that carried it. */ +typealias Value = uniffi.moq.MoqJsonValue + +/** The frame's timestamp on the broadcast clock, or null when untimed. */ +val Value.timestamp: Duration? + get() = timestampUs?.toLong()?.microseconds + +/** A decoded JSON value and its frame timestamp on the broadcast clock, or null when untimed. */ +data class Timed(val value: T, val timestamp: Duration?) + +/** Decode [Value.value] as [T], keeping the timestamp. */ +inline fun Value.decode(): Timed = + Timed(MoqJson.decodeFromString(serializer(), value), timestamp) /** * Publish [value] as a JSON snapshot, superseding the last. A no-op when unchanged. @@ -48,10 +63,12 @@ inline fun StreamProducer.append(value: T, timestampUs: ULong? = nul } /** - * Stream of JSON values (as strings) from a snapshot track, yielding the latest reconstructed - * value. A consumer that has fallen behind collapses the backlog to the latest. + * Stream of each JSON state from a snapshot track, in order. + * + * Groups older than the subscription's `maxDelayUs` are skipped, so a playhead reader sets that + * budget to its distance behind live. Call [SnapshotConsumer.latest] to skip to the newest state. */ -fun SnapshotConsumer.values(): Flow = flow { +fun SnapshotConsumer.values(): Flow = flow { while (true) { currentCoroutineContext().ensureActive() emit(next() ?: break) @@ -60,8 +77,8 @@ fun SnapshotConsumer.values(): Flow = flow { if (cause is CancellationException) cancel() } -/** Stream of JSON records (as strings) from a stream track, in order. */ -fun StreamConsumer.values(): Flow = flow { +/** Stream of JSON records from a stream track, in order. */ +fun StreamConsumer.values(): Flow = flow { while (true) { currentCoroutineContext().ensureActive() emit(next() ?: break) @@ -71,18 +88,17 @@ fun StreamConsumer.values(): Flow = flow { } /** - * Stream of decoded snapshot values, yielding the latest reconstructed value. + * Stream of decoded snapshot states, in order. * - * A consumer that has fallen behind collapses the backlog to the latest. Use - * [SnapshotConsumer.values] for the undecoded JSON strings. + * Use [SnapshotConsumer.values] for the undecoded JSON strings. */ -inline fun SnapshotConsumer.valuesAs(): Flow = - values().map { MoqJson.decodeFromString(serializer(), it) } +inline fun SnapshotConsumer.valuesAs(): Flow> = + values().map { it.decode() } /** * Stream of decoded stream records, in order. * * Use [StreamConsumer.values] for the undecoded JSON strings. */ -inline fun StreamConsumer.valuesAs(): Flow = - values().map { MoqJson.decodeFromString(serializer(), it) } +inline fun StreamConsumer.valuesAs(): Flow> = + values().map { it.decode() } diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index 8d68faba273..d9df753d9d7 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -12,6 +12,8 @@ import dev.moq.json.SnapshotConfig import dev.moq.json.SnapshotConsumer import dev.moq.json.SnapshotProducer import dev.moq.json.StreamConfig +import dev.moq.json.Timed +import dev.moq.json.decode import dev.moq.json.update import dev.moq.json.valuesAs import kotlinx.serialization.Serializable @@ -372,12 +374,16 @@ class SmokeTest { @Test fun `typed json snapshot round-trips a serializable value`() = runTest { BroadcastProducer().use { broadcast -> - val config = SnapshotConfig(deltaRatio = 0u, compression = false) - val producer = SnapshotProducer(broadcast, broadcast.publishTrack("status", null), config) - producer.update(Status(state = "live")) - + val config = SnapshotConfig(deltaRatio = 8u, compression = false) + val track = broadcast.publishTrack("status", TrackInfo(timescale = 1_000_000uL)) + val producer = SnapshotProducer(broadcast, track, config) val consumer = SnapshotConsumer(broadcast.consume().subscribeTrack("status", null), config) - assertEquals(Status(state = "live"), consumer.valuesAs().first()) + for (n in 1..4) producer.update(Status(state = "live$n"), timestampUs = n.toULong() * 1_000uL) + + // `next` yields every state in order; `latest` skips the buffered third. + assertEquals(Timed(Status(state = "live1"), 1.milliseconds), consumer.next()?.decode()) + assertEquals(Timed(Status(state = "live2"), 2.milliseconds), consumer.next()?.decode()) + assertEquals(Timed(Status(state = "live4"), 4.milliseconds), consumer.latest()?.decode()) } } @@ -410,7 +416,7 @@ class SmokeTest { producer.update("""{"state":"raw"}""") val consumer = SnapshotConsumer(broadcast.consume().subscribeTrack("status", null), config) - assertEquals(Status(state = "raw"), consumer.valuesAs().first()) + assertEquals(Timed(Status(state = "raw"), null), consumer.valuesAs().first()) } } diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index b9d7a592fa5..d85db06a23c 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -125,6 +125,7 @@ broadcast, and producers advertise it in the catalog. StreamProducer SnapshotConsumer StreamConsumer + Timed ``` ## Opaque data diff --git a/py/moq-rs/moq/json.py b/py/moq-rs/moq/json.py index a82414a1863..3155a427d7b 100644 --- a/py/moq-rs/moq/json.py +++ b/py/moq-rs/moq/json.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +from dataclasses import dataclass from datetime import timedelta from typing import Any @@ -19,13 +20,31 @@ MoqJsonStreamConfig, MoqJsonStreamConsumer, MoqJsonStreamProducer, + MoqJsonValue, ) -from ._records import _opt_us +from ._records import _opt_from_us, _opt_us from .publish import BroadcastProducer, TrackDemand, TrackProducer from .subscribe import TrackConsumer -__all__ = ["SnapshotConsumer", "SnapshotProducer", "StreamConsumer", "StreamProducer"] +__all__ = ["SnapshotConsumer", "SnapshotProducer", "StreamConsumer", "StreamProducer", "Timed"] + + +@dataclass(frozen=True) +class Timed: + """A parsed JSON value and the timestamp of the frame that carried it. + + ``timestamp`` is on the broadcast clock, and ``None`` on an untimed track. + """ + + value: Any + timestamp: timedelta | None + + @staticmethod + def _from_ffi(value: MoqJsonValue | None) -> Timed | None: + if value is None: + return None + return Timed(value=json.loads(value.value), timestamp=_opt_from_us(value.timestamp_us)) class SnapshotProducer: @@ -93,11 +112,12 @@ def finish(self) -> None: class SnapshotConsumer: - """Async iterator over a JSON snapshot track, yielding the latest value (lossy). + """Async iterator over a JSON snapshot track, yielding each state in order (lossy). - Each item is a parsed Python object. A consumer that has fallen behind collapses the backlog - and yields only the latest value. Pass the same ``compression`` the producer used. Usable as - an async context manager that cancels on exit. + Each item is a :class:`Timed`. Groups older than the subscription's ``max_delay`` are skipped, + so a playhead reader sets that budget to its distance behind live; :meth:`latest` skips to the + newest state instead. Pass the same ``compression`` the producer used. Usable as an async + context manager that cancels on exit. """ def __init__(self, track: TrackConsumer, *, compression: bool = False) -> None: @@ -114,22 +134,29 @@ async def __aexit__(self, *exc) -> None: def __aiter__(self): return self - async def __anext__(self) -> Any: - value = await self._inner.next() + async def __anext__(self) -> Timed: + value = Timed._from_ffi(await self._inner.next()) if value is None: raise StopAsyncIteration - return json.loads(value) + return value + + async def latest(self) -> Timed | None: + """Skip to the newest state, or wait for one. ``None`` once the track ends. + + Skipped states are gone; iteration continues after this one. + """ + return Timed._from_ffi(await self._inner.latest()) def cancel(self) -> None: - """Cancel all current and future next() calls.""" + """Cancel all current and future reads.""" self._inner.cancel() class StreamConsumer: """Async iterator over a JSON stream track, yielding every record in order (lossless). - Each item is a parsed Python object. Pass the same ``compression`` the producer used. Usable - as an async context manager that cancels on exit. + Each item is a :class:`Timed`. Pass the same ``compression`` the producer used. Usable as an + async context manager that cancels on exit. """ def __init__(self, track: TrackConsumer, *, compression: bool = False) -> None: @@ -145,11 +172,11 @@ async def __aexit__(self, *exc) -> None: def __aiter__(self): return self - async def __anext__(self) -> Any: - value = await self._inner.next() + async def __anext__(self) -> Timed: + value = Timed._from_ffi(await self._inner.next()) if value is None: raise StopAsyncIteration - return json.loads(value) + return value def cancel(self) -> None: """Cancel all current and future next() calls.""" diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index f5225b9f09d..7f565d2be12 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -477,18 +477,23 @@ async def test_fetch_group_and_serve_dynamic_miss(): async def test_json_snapshot_roundtrip(): broadcast = moq.BroadcastProducer() - producer = moq.json.SnapshotProducer(broadcast, broadcast.publish_track("status"), compression=True) + track = broadcast.publish_track("status", moq.TrackInfo(timescale=1_000_000)) + producer = moq.json.SnapshotProducer(broadcast, track, compression=True) consumer = moq.json.SnapshotConsumer(await broadcast.consume().subscribe_track("status"), compression=True) - producer.update({"state": "live", "viewers": 1}) - value = await asyncio.wait_for(anext(consumer), timeout=5.0) - assert value == {"state": "live", "viewers": 1} + for viewers in range(1, 5): + producer.update({"viewers": viewers}, timestamp=timedelta(milliseconds=viewers)) - producer.update({"state": "live", "viewers": 2}) + # Iteration yields every state in order; latest() skips the buffered third. + value = await asyncio.wait_for(anext(consumer), timeout=5.0) + assert value == moq.json.Timed({"viewers": 1}, timedelta(milliseconds=1)) value = await asyncio.wait_for(anext(consumer), timeout=5.0) - assert value == {"state": "live", "viewers": 2} + assert value == moq.json.Timed({"viewers": 2}, timedelta(milliseconds=2)) + value = await asyncio.wait_for(consumer.latest(), timeout=5.0) + assert value == moq.json.Timed({"viewers": 4}, timedelta(milliseconds=4)) producer.finish() + assert await asyncio.wait_for(consumer.latest(), timeout=5.0) is None async def test_json_stream_roundtrip(): @@ -499,7 +504,7 @@ async def test_json_stream_roundtrip(): for n in range(3): producer.append({"n": n}) record = await asyncio.wait_for(anext(consumer), timeout=5.0) - assert record == {"n": n} + assert record == moq.json.Timed({"n": n}, None) producer.finish() diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index ea76d27838f..f5b945a3334 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -456,6 +456,13 @@ pub struct moq_json_value { /// The JSON document as UTF-8, NOT NULL terminated. pub json: *const c_char, pub json_len: usize, + + /// The timestamp of the frame that carried the value in microseconds, or 0 when + /// `timestamp_present` is false. + pub timestamp_us: u64, + + /// Whether the frame carries a timestamp. A value read from an untimed track does not. + pub timestamp_present: bool, } /// Information about a frame of media. @@ -3719,8 +3726,8 @@ pub extern "C" fn moq_consume_datagrams_cancel(task: u32) -> i32 { /// Subscribe to a JSON snapshot track (lossy latest-value) by name. /// -/// `on_value` is called with a positive value ID for each new latest value; a consumer that -/// falls behind collapses the backlog and only sees the newest. It is called exactly once more +/// `on_value` is called with a positive value ID for each state, in order; a group superseded by +/// a newer one before it is read is skipped, so a slow reader stays bounded. It is called exactly once more /// with a terminal `0` (track ended / closed) or a negative error, after which `user_data` is /// never touched again, so release it there. Read each value with [moq_consume_json_value] and /// release it with [moq_consume_json_value_free]. Pass the same compression the producer used. @@ -3789,7 +3796,7 @@ pub unsafe extern "C" fn moq_consume_json_stream( /// Read a JSON value delivered via a [moq_consume_json_snapshot] or [moq_consume_json_stream] callback. /// -/// Fills `dst.json` / `dst.json_len`; the pointer is valid until the value is released with +/// Fills `dst.json` / `dst.json_len` and the frame timestamp; the pointer is valid until the value is released with /// [moq_consume_json_value_free]. /// /// Returns a zero on success, or a negative code on failure. diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 48e2c6acbf4..08adfcff3f2 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -121,7 +121,7 @@ enum RawStep { Stop, } -/// A raw frame or datagram timestamp in microseconds, `None` when untimed. +/// A raw frame, datagram, or JSON value timestamp in microseconds, `None` when untimed. fn timestamp_us(timestamp: Option) -> Result, Error> { timestamp .map(|timestamp| timestamp.as_micros().try_into()) @@ -129,6 +129,21 @@ fn timestamp_us(timestamp: Option) -> Result, Er .map_err(|_| Error::TimestampOverflow(moq_net::TimeOverflow)) } +/// A JSON value pre-serialized to a string, with its frame timestamp. +struct JsonValue { + json: String, + timestamp_us: Option, +} + +impl JsonValue { + fn new(value: moq_net::Timed) -> Result { + Ok(Self { + json: serde_json::to_string(&value.value)?, + timestamp_us: timestamp_us(value.at)?, + }) + } +} + #[derive(Default)] pub struct Consume { /// Active broadcast consumers. @@ -161,8 +176,8 @@ pub struct Consume { /// JSON consumer tasks (snapshot and stream share this slab). json_task: NonZeroSlab>, - /// Buffered JSON values ready for consumption, pre-serialized to a string. - json_value: NonZeroSlab, + /// Buffered JSON values ready for consumption. + json_value: NonZeroSlab, } impl Consume { @@ -933,8 +948,8 @@ impl Consume { /// Subscribe to a JSON snapshot track (lossy latest-value) by name. /// - /// `on_value` is called with a value ID for each new latest value; a consumer that falls - /// behind collapses the backlog and only sees the newest. Values must be released with + /// `on_value` is called with a value ID for each state, in order; a group superseded by a + /// newer one before it is read is skipped. Values must be released with /// [`Self::json_value_close`]. Pass the same compression the producer used. pub fn json_snapshot( &mut self, @@ -995,9 +1010,8 @@ impl Consume { }, }; - let json = serde_json::to_string(&value)?; // Hold the lock only to buffer the value; release it before the callback. - let value_id = State::lock().consume.json_value.insert(json)?; + let value_id = State::lock().consume.json_value.insert(JsonValue::new(value)?)?; callback.call(Ok(value_id)); } } @@ -1063,8 +1077,7 @@ impl Consume { }, }; - let json = serde_json::to_string(&value)?; - let value_id = State::lock().consume.json_value.insert(json)?; + let value_id = State::lock().consume.json_value.insert(JsonValue::new(value)?)?; callback.call(Ok(value_id)); } } @@ -1085,11 +1098,13 @@ impl Consume { /// Fill `dst` with a JSON value delivered via a consumer callback. The pointer is valid /// until the value is released with [`Self::json_value_close`]. pub fn json_value(&self, value: Id, dst: &mut moq_json_value) -> Result<(), Error> { - let json = self.json_value.get(value).ok_or(Error::FrameNotFound)?; + let value = self.json_value.get(value).ok_or(Error::FrameNotFound)?; *dst = moq_json_value { - json: json.as_str().as_ptr() as *const c_char, - json_len: json.len(), + json: value.json.as_ptr() as *const c_char, + json_len: value.json.len(), + timestamp_us: value.timestamp_us.unwrap_or_default(), + timestamp_present: value.timestamp_us.is_some(), }; Ok(()) diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index a52149f489f..f63f0cb7e44 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -1821,6 +1821,7 @@ fn json_snapshot_publish_consume() { ) }); + let mut last_us = 0; for expected in [r#"{"a":1}"#, r#"{"a":2}"#] { assert_eq!( unsafe { moq_publish_json_snapshot_update(producer, expected.as_ptr() as *const c_char, expected.len()) }, @@ -1830,8 +1831,14 @@ fn json_snapshot_publish_consume() { let mut value = moq_json_value { json: std::ptr::null(), json_len: 0, + timestamp_us: 0, + timestamp_present: false, }; assert_eq!(unsafe { moq_consume_json_value(value_id, &mut value) }, 0); + // The producer stamps each value with the broadcast clock. + assert!(value.timestamp_present); + assert!(value.timestamp_us >= last_us); + last_us = value.timestamp_us; let received = unsafe { std::slice::from_raw_parts(value.json.cast::(), value.json_len) }; assert_eq!( serde_json::from_slice::(received).unwrap(), @@ -1884,6 +1891,7 @@ fn json_stream_publish_consume() { ) }); + let mut last_us = 0; for expected in [r#"{"n":0}"#, r#"{"n":1}"#, r#"{"n":2}"#] { assert_eq!( unsafe { moq_publish_json_stream_append(producer, expected.as_ptr() as *const c_char, expected.len()) }, @@ -1893,8 +1901,14 @@ fn json_stream_publish_consume() { let mut value = moq_json_value { json: std::ptr::null(), json_len: 0, + timestamp_us: 0, + timestamp_present: false, }; assert_eq!(unsafe { moq_consume_json_value(value_id, &mut value) }, 0); + // The producer stamps each value with the broadcast clock. + assert!(value.timestamp_present); + assert!(value.timestamp_us >= last_us); + last_us = value.timestamp_us; let received = unsafe { std::slice::from_raw_parts(value.json.cast::(), value.json_len) }; assert_eq!( serde_json::from_slice::(received).unwrap(), diff --git a/rs/moq-ffi/src/json.rs b/rs/moq-ffi/src/json.rs index 429c7963361..3fa697d56a6 100644 --- a/rs/moq-ffi/src/json.rs +++ b/rs/moq-ffi/src/json.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use serde_json::Value; -use crate::consumer::MoqTrackConsumer; +use crate::consumer::{MoqTrackConsumer, timestamp_us}; use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::Task; @@ -166,20 +166,30 @@ impl MoqJsonSnapshotProducer { } } -struct SnapshotConsumer { - inner: moq_json::snapshot::Consumer, +/// A JSON value read from a track, with the timestamp of the frame that carried it. +#[derive(Clone, Debug, PartialEq, uniffi::Record)] +pub struct MoqJsonValue { + /// The JSON document. + pub value: String, + /// The frame's timestamp on the broadcast clock in microseconds, or null when untimed. + pub timestamp_us: Option, } -impl SnapshotConsumer { - async fn next(&mut self) -> Result, MoqError> { - match self.inner.next().await? { - Some(value) => Ok(Some(serde_json::to_string(&value)?)), - None => Ok(None), - } +impl MoqJsonValue { + fn new(timed: Option>) -> Result, MoqError> { + let Some(timed) = timed else { return Ok(None) }; + Ok(Some(Self { + value: serde_json::to_string(&timed.value)?, + timestamp_us: timed.at.map(timestamp_us).transpose()?, + })) } } -/// Consumes a JSON snapshot track, yielding the latest reconstructed value. +struct SnapshotConsumer { + inner: moq_json::snapshot::Consumer, +} + +/// Consumes a JSON snapshot track, yielding each reconstructed state or skipping to the newest. #[derive(uniffi::Object)] pub struct MoqJsonSnapshotConsumer { task: Task, @@ -201,14 +211,26 @@ impl MoqJsonSnapshotConsumer { })) } - /// Get the next value as a JSON string. Returns `None` once the track ends. + /// Get the next state in order. Returns `None` once the track ends. /// - /// A consumer that has fallen behind collapses the backlog and yields only the latest value. - pub async fn next(&self) -> Result, MoqError> { - self.task.run(|mut state| async move { state.next().await }).await + /// Groups older than the subscription's `max_delay_us` are skipped, so a playhead + /// reader sets that budget to its distance behind live. + pub async fn next(&self) -> Result, MoqError> { + self.task + .run(|mut state| async move { MoqJsonValue::new(state.inner.next().await?) }) + .await } - /// Cancel all current and future `next()` calls. + /// Skip to the newest state, or wait for one. Returns `None` once the track ends. + /// + /// Skipped states are gone; a later `next()` continues after this one. + pub async fn latest(&self) -> Result, MoqError> { + self.task + .run(|mut state| async move { MoqJsonValue::new(state.inner.latest().await?) }) + .await + } + + /// Cancel all current and future `next()` and `latest()` calls. /// /// Terminal: the subscription is released here, not when the handle is. pub fn cancel(&self) { @@ -283,15 +305,6 @@ struct StreamConsumer { inner: moq_json::stream::Consumer, } -impl StreamConsumer { - async fn next(&mut self) -> Result, MoqError> { - match self.inner.next().await? { - Some(value) => Ok(Some(serde_json::to_string(&value)?)), - None => Ok(None), - } - } -} - /// Consumes an ordered log of JSON records, yielding every record in order. #[derive(uniffi::Object)] pub struct MoqJsonStreamConsumer { @@ -314,9 +327,11 @@ impl MoqJsonStreamConsumer { })) } - /// Get the next record as a JSON string. Returns `None` once the track ends. - pub async fn next(&self) -> Result, MoqError> { - self.task.run(|mut state| async move { state.next().await }).await + /// Get the next record. Returns `None` once the track ends. + pub async fn next(&self) -> Result, MoqError> { + self.task + .run(|mut state| async move { MoqJsonValue::new(state.inner.next().await?) }) + .await } /// Cancel all current and future `next()` calls. diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 633ec70bba1..abe9fa08ebe 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -943,6 +943,7 @@ async fn catalog_now_fixes_the_clock_for_later_media() { assert!(matches!(catalog.now(), Err(MoqError::Closed))); } +/// `next` yields every state in order with its timestamp; `latest` skips to the newest. #[tokio::test] async fn json_snapshot_roundtrip() { let broadcast = MoqBroadcastProducer::new().unwrap(); @@ -950,7 +951,12 @@ async fn json_snapshot_roundtrip() { delta_ratio: 8, compression: true, }; - let track = broadcast.publish_track("meta".into(), None).unwrap(); + let info = MoqTrackInfo { + priority: 127, + max_age_us: None, + timescale: Some(1_000_000), + }; + let track = broadcast.publish_track("meta".into(), Some(info)).unwrap(); let producer = MoqJsonSnapshotProducer::new(&broadcast, &track, config.clone()).unwrap(); assert!( matches!(track.demand(), Err(MoqError::Closed)), @@ -958,34 +964,54 @@ async fn json_snapshot_roundtrip() { ); let consumer = MoqJsonSnapshotConsumer::new(&*subscribe(&broadcast, "meta").await, config).unwrap(); - producer.update(r#"{"a":1}"#.into(), None).unwrap(); + for n in 1..=4 { + producer.update(format!(r#"{{"a":{n}}}"#), Some(n * 1000)).unwrap(); + } + let value = tokio::time::timeout(TIMEOUT, consumer.next()) .await .expect("timed out waiting for json snapshot") .unwrap() .expect("expected a value"); assert_eq!( - serde_json::from_str::(&value).unwrap(), + serde_json::from_str::(&value.value).unwrap(), serde_json::json!({ "a": 1 }) ); + assert_eq!(value.timestamp_us, Some(1000)); - // A second update supersedes the first; a late reader collapses to the latest. - producer.update(r#"{"a":2}"#.into(), None).unwrap(); let value = tokio::time::timeout(TIMEOUT, consumer.next()) .await .expect("timed out waiting for json snapshot delta") .unwrap() .expect("expected a value"); assert_eq!( - serde_json::from_str::(&value).unwrap(), + serde_json::from_str::(&value.value).unwrap(), serde_json::json!({ "a": 2 }) ); + assert_eq!(value.timestamp_us, Some(2000)); + + // `latest` skips the buffered third state. + let value = tokio::time::timeout(TIMEOUT, consumer.latest()) + .await + .expect("timed out waiting for the latest json snapshot") + .unwrap() + .expect("expected a value"); + assert_eq!( + serde_json::from_str::(&value.value).unwrap(), + serde_json::json!({ "a": 4 }) + ); + assert_eq!(value.timestamp_us, Some(4000)); producer.finish().unwrap(); assert!(matches!( - producer.update(r#"{"a":3}"#.into(), None), + producer.update(r#"{"a":5}"#.into(), Some(5000)), Err(MoqError::Closed) )); + let end = tokio::time::timeout(TIMEOUT, consumer.next()) + .await + .expect("timed out waiting for the end of the json snapshot") + .unwrap(); + assert_eq!(end, None); } /// JSON producers report subscriber demand through a handle, like the media and raw track producers. @@ -1094,9 +1120,10 @@ async fn json_stream_roundtrip() { .unwrap() .expect("expected a record"); assert_eq!( - serde_json::from_str::(&value).unwrap(), + serde_json::from_str::(&value.value).unwrap(), serde_json::json!({ "n": n }) ); + assert_eq!(value.timestamp_us, None, "an untimed track yields no timestamp"); } producer.finish().unwrap(); } @@ -1131,7 +1158,7 @@ async fn json_on_requested_track() { .expect("timed out waiting for json stream record") .unwrap() .expect("expected a record"); - assert_eq!(value, r#"{"n":1}"#); + assert_eq!(value.value, r#"{"n":1}"#); } /// A JSON consumer refuses a track that has already read a group, and closes one it takes over. diff --git a/rs/moq-json/examples/teleop.rs b/rs/moq-json/examples/teleop.rs index 59ba7009566..0d9499a0bc0 100644 --- a/rs/moq-json/examples/teleop.rs +++ b/rs/moq-json/examples/teleop.rs @@ -52,18 +52,21 @@ fn main() -> Result<(), Box> { commands.finish()?; let waiter = kio::Waiter::noop(); - let Poll::Ready(Ok(Some(latest))) = state_reader.poll_next(&waiter) else { + // `latest` skips the stale steering values; `next` would replay them in order. + let Poll::Ready(Ok(Some(latest))) = state_reader.poll_latest(&waiter) else { return Err("finished control track did not yield its latest state".into()); }; - assert_eq!(latest, Control { speed: 0, turn: 0 }); - println!("Current control: {latest:?}"); - assert!(matches!(state_reader.poll_next(&waiter), Poll::Ready(Ok(None)))); - for expected in expected { + assert_eq!(latest.value, Control { speed: 0, turn: 0 }); + assert_eq!(latest.at, Some(Timestamp::from_millis(2)?)); + println!("Current control: {:?} at {:?}", latest.value, latest.at); + assert!(matches!(state_reader.poll_latest(&waiter), Poll::Ready(Ok(None)))); + for (tick, expected) in expected.into_iter().enumerate() { let Poll::Ready(Ok(Some(command))) = command_reader.poll_next(&waiter) else { return Err("finished command track lost an ordered command".into()); }; - assert_eq!(command, expected); - println!("Command: {command:?}"); + assert_eq!(command.value, expected); + assert_eq!(command.at, Some(Timestamp::from_millis(tick as u64)?)); + println!("Command: {:?} at {:?}", command.value, command.at); } assert!(matches!(command_reader.poll_next(&waiter), Poll::Ready(Ok(None)))); Ok(()) diff --git a/swift/Sources/Moq/Json.swift b/swift/Sources/Moq/Json.swift index e1f36f3acf0..446108bb59e 100644 --- a/swift/Sources/Moq/Json.swift +++ b/swift/Sources/Moq/Json.swift @@ -12,6 +12,25 @@ import MoqFFI public enum Json {} extension Json { + /// A decoded JSON value and the timestamp of the frame that carried it. + public struct Timed { + /// The decoded value. + public let value: Value + /// The frame's timestamp on the broadcast clock in microseconds, or `nil` when untimed. + public let timestampUs: UInt64? + + /// Pair `value` with its frame timestamp. + public init(value: Value, timestampUs: UInt64?) { + self.value = value + self.timestampUs = timestampUs + } + + init?(_ ffi: MoqJsonValue?) throws where Value: Decodable { + guard let ffi else { return nil } + self.init(value: try decodeJson(ffi.value), timestampUs: ffi.timestampUs) + } + } + /// Write side of a JSON snapshot track (lossy latest-value). /// /// Each `update` supersedes the last, so a late joiner only sees the newest value. @@ -55,11 +74,12 @@ extension Json { } } - /// Read side of a JSON snapshot track (lossy latest-value). Iterating yields the latest value, - /// collapsing the backlog for a reader that has fallen behind: `for try await value in json { ... }`. + /// Read side of a JSON snapshot track (lossy latest-value). Iterating yields each state in + /// order with its timestamp: `for try await state in json { ... }`. Groups older than the + /// subscription's `maxDelayUs` are skipped; `latest()` skips to the newest state instead. public final class SnapshotConsumer: AsyncSequence, Sendable { - /// The decoded snapshot value emitted by this sequence. - public typealias Element = Value + /// The decoded snapshot state emitted by this sequence. + public typealias Element = Timed let ffi: MoqJsonSnapshotConsumer @@ -71,10 +91,15 @@ extension Json { ffi = try MoqJsonSnapshotConsumer(track: track.ffi, config: MoqJsonSnapshotConfig(compression: compression)) } - /// The next value, decoded as `Value`, or `nil` once the track ends or is closed. - public func next() async throws -> Value? { - guard let json = try await ffi.next() else { return nil } - return try decodeJson(json) as Value + /// The next state in order, or `nil` once the track ends or is closed. + public func next() async throws -> Timed? { + try Timed(try await ffi.next()) + } + + /// Skip to the newest state, or wait for one; `nil` once the track ends or is closed. + /// Skipped states are gone, and `next()` continues after this one. + public func latest() async throws -> Timed? { + try Timed(try await ffi.latest()) } /// Cancel all current and future reads. @@ -83,7 +108,7 @@ extension Json { } /// Create an iterator that cancels native reads when iteration ends. - public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + public func makeAsyncIterator() -> AsyncThrowingStream, Swift.Error>.Iterator { moqStream(cancel: { [ffi] in ffi.cancel() }) { [self] in try await next() }.makeAsyncIterator() @@ -128,7 +153,7 @@ extension Json { /// order: `for try await record in json { ... }`. public final class StreamConsumer: AsyncSequence, Sendable { /// The decoded stream record emitted by this sequence. - public typealias Element = Value + public typealias Element = Timed let ffi: MoqJsonStreamConsumer @@ -139,10 +164,9 @@ extension Json { ffi = try MoqJsonStreamConsumer(track: track.ffi, config: MoqJsonStreamConfig(compression: compression)) } - /// The next record, decoded as `Value`, or `nil` once the track ends or is closed. - public func next() async throws -> Value? { - guard let json = try await ffi.next() else { return nil } - return try decodeJson(json) as Value + /// The next record, or `nil` once the track ends or is closed. + public func next() async throws -> Timed? { + try Timed(try await ffi.next()) } /// Cancel all current and future reads. @@ -151,7 +175,7 @@ extension Json { } /// Create an iterator that cancels native reads when iteration ends. - public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + public func makeAsyncIterator() -> AsyncThrowingStream, Swift.Error>.Iterator { moqStream(cancel: { [ffi] in ffi.cancel() }) { [self] in try await next() }.makeAsyncIterator() @@ -159,6 +183,9 @@ extension Json { } } +extension Json.Timed: Sendable where Value: Sendable {} +extension Json.Timed: Equatable where Value: Equatable {} + private func encodeJson(_ value: some Encodable) throws -> String { String(decoding: try JSONEncoder().encode(value), as: UTF8.self) } diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index c47d90a4161..2f001478873 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -258,18 +258,23 @@ final class SmokeTests: XCTestCase { let broadcast = try BroadcastProducer() let producer = try Json.SnapshotProducer( - broadcast: broadcast, track: try broadcast.publishTrack(name: "status"), compression: true) + broadcast: broadcast, + track: try broadcast.publishTrack(name: "status", info: TrackInfo(timescale: 1_000_000)), + compression: true) let consumer = try Json.SnapshotConsumer( track: try await broadcast.consume().subscribeTrack(name: "status"), compression: true) - try producer.update(Status(state: "live", viewers: 42)) - let first = try await consumer.next() - XCTAssertEqual(first, Status(state: "live", viewers: 42)) + for viewers in 1...4 { + try producer.update(Status(state: "live", viewers: viewers), timestampUs: UInt64(viewers) * 1000) + } - // A second update supersedes the first; a late reader collapses to the latest. - try producer.update(Status(state: "live", viewers: 43)) + // `next` yields every state in order; `latest` skips the buffered third. + let first = try await consumer.next() + XCTAssertEqual(first, Json.Timed(value: Status(state: "live", viewers: 1), timestampUs: 1000)) let second = try await consumer.next() - XCTAssertEqual(second, Status(state: "live", viewers: 43)) + XCTAssertEqual(second, Json.Timed(value: Status(state: "live", viewers: 2), timestampUs: 2000)) + let latest = try await consumer.latest() + XCTAssertEqual(latest, Json.Timed(value: Status(state: "live", viewers: 4), timestampUs: 4000)) consumer.cancel() try producer.finish() @@ -290,7 +295,7 @@ final class SmokeTests: XCTestCase { for n in 0..<3 { try producer.append(Event(n: n)) let record = try await consumer.next() - XCTAssertEqual(record, Event(n: n)) + XCTAssertEqual(record, Json.Timed(value: Event(n: n), timestampUs: nil)) } consumer.cancel() From 31bfd68c4dea4933fcdf5d02bbe56c9b0a8c2965 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 15:06:57 -0700 Subject: [PATCH 05/12] quest: complete data consumer timestamps Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/data-consumer-timestamps.md | 45 ---------------------------- quest/m1/json-window-timestamps.md | 13 +++----- quest/m2/flate.md | 7 ++--- 4 files changed, 6 insertions(+), 60 deletions(-) delete mode 100644 quest/m1/data-consumer-timestamps.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 9162652ca96..43d507071b8 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -72,7 +72,6 @@ explicit exception to the public model audit. - [Announce caught up](/quest/m1/announce-live.md) - Rust and JS announce consumers expose when a session's initial announce snapshot has arrived, from the counts the wire already carries where a version has them - [A spinning loop fails a sim test](/quest/m1/spinning-loops.md) - a sim test names any loop that holds a task poll too long, and each one yields through a budget - [Importers announce with a track](/quest/m1/import-declared-track.md) - every importer announces once its catalog names a servable track (a rendition or an extension track like `m2ts`), and input that ends with none exits non-zero unannounced -- [Data consumer timestamps](/quest/m1/data-consumer-timestamps.md) - json and binary consumers return each value's timestamp, in Rust and every binding; snapshots add `latest()` beside an in-order `next()` - [JSON window timestamps](/quest/m1/json-window-timestamps.md) - Rust and JS window consumers return each event's frame timestamp like the snapshot and stream consumers - [JavaScript FETCH](/quest/m1/js-fetch.md) - browser publishers answer IETF FETCH through the JS ranges request surface - [IETF fill timeout](/quest/m1/ietf-fill-timeout.md) - native FILL_TIMEOUT bounds upstream waiting on drafts 18-22, alongside local content-age enforcement diff --git a/quest/m1/data-consumer-timestamps.md b/quest/m1/data-consumer-timestamps.md deleted file mode 100644 index 334c3a3f01c..00000000000 --- a/quest/m1/data-consumer-timestamps.md +++ /dev/null @@ -1,45 +0,0 @@ -# [M] Data consumers return each value's timestamp - -## Goal - -A JSON or binary data consumer returns each value with its frame's timestamp, -so an application can carry data onto another track, or sync it with video, -at the exact time it was published. Covers moq-json, moq-flate, the moq-mux -wrappers, moq-ffi, and every binding wrapper. - -## Plan - -An application that translates MAVLink into its own telemetry must keep -each value's timestamp to stay in sync with video. - -Every `moq_net::Frame` carries an optional timestamp (`None` when untimed, -since #4822), but the consumers decode only `frame.payload` and drop it: `moq_mux::{json,binary}::Consumer::next` and the -moq-json and moq-flate snapshot and stream consumers they wrap. Each snapshot -state carries the timestamp of the frame that produced it. - -Decided: `next()` and `poll_next()` return `Timed`, the same type the producers take. -`at` is the frame's media timestamp on the track's timescale, and `None` for -an untimed frame: absence survives the wire rather than becoming arrival -time (decided 2026-10-01, landed in [#4822](https://github.com/moq-dev/moq/pull/4822)). A republisher passes `at` straight to a moq-mux data producer. - -Decided (2026-10-01): snapshot consumers get two reads, both returning -`Timed`. Today the moq-json snapshot consumer applies every buffered delta -but yields only the newest state, and moq-flate jumps to the newest group, so -a 9s state is lost when 11s is already buffered. A caller syncing to a -playhead needs the newest state at or before it. - -- `next()` (and `poll_next()`) yields every state in order, like a stream - consumer. A reader that falls behind the drift budget (`Lagged`) still - jumps to the newest group, so a slow reader stays bounded. -- `latest()` (and `poll_latest()`) skips to the newest state, today's - behavior, kept as the optimization for callers that only want the current - value. -- Same names in the moq-mux wrappers, moq-ffi, and every binding. - -Existing moq-ffi consumers return the timestamp too, and the py, swift, -kt, go, and dart wrappers and `doc/lib/*` follow. Flate FFI consumers do not -yet exist: [Flate bindings](/quest/m2/flate.md) creates them directly with -this contract, so this quest neither duplicates that work nor waits on m2. `@moq/json` and `@moq/flate` -already return `Timed` with the same `next()` and `latest()`. - -Public API: breaking. Wire: none. diff --git a/quest/m1/json-window-timestamps.md b/quest/m1/json-window-timestamps.md index b3de92da6a9..6a34a9a7693 100644 --- a/quest/m1/json-window-timestamps.md +++ b/quest/m1/json-window-timestamps.md @@ -3,9 +3,8 @@ ## Goal The moq-json and `@moq/json` window consumers return each event with a -timestamp. In JS all three data consumers then return `Timed`; the Rust -snapshot and stream consumers follow in -[Data consumer timestamps](/quest/m1/data-consumer-timestamps.md). +timestamp. The Rust and JS snapshot and stream consumers already return +`Timed` (#5099, #5677); the window consumer is the last one. ## Plan @@ -22,11 +21,7 @@ stream consumers `Timed`. The window consumer (`window::Consumer::next` in - Update in-repo readers (`js/hang` timeline, `js/room` chat) and add a line to the Unreleased section of `doc/setup/upgrade.md`. No new doc page. - Land before the next `@moq/json` release, so its break ships together with - #5099's. Not blocked on the Rust snapshot and stream quest: both Rust breaks - use the same `moq_net::Timed` and ship in the next moq-json release together. + #5099's. The Rust break uses the same `moq_net::Timed` as #5677 and ships in + the next moq-json release with it. Public API: breaking (Rust and JS). Wire: none. - -## Related - -- [Data consumer timestamps](/quest/m1/data-consumer-timestamps.md) - the Rust snapshot and stream side of the same change diff --git a/quest/m2/flate.md b/quest/m2/flate.md index 1ef5086742b..0202d4a1d54 100644 --- a/quest/m2/flate.md +++ b/quest/m2/flate.md @@ -28,7 +28,8 @@ What remains: - moq-ffi has no consume side for flate tracks. Add consumers mirroring the `json` ones so each wrapper can read what it writes. - Add consumers to each wrapper's `flate` namespace, following `json` and - the timestamp-preserving `next()` / `latest()` contract. + the timestamp-preserving `next()` / `latest()` contract: return a payload + plus `timestamp_us` record shaped like `MoqJsonValue` (#5677). - Document in `doc/lib/{py,swift,kt,go,dart}` beside the JSON entry. - Tests: a round trip in each wrapper that has tests, and one cross-language check that a wrapper-published group decodes with `@moq/flate`. Run @@ -37,7 +38,3 @@ What remains: Public API: additive on top of #4519. Wire, catalog, and relay: none. Compression stays invisible to `moq-net`; a compressed track is announced, routed, and cached like any other. - -## Required - -- [Data consumer timestamps](/quest/m1/data-consumer-timestamps.md) - establishes the consumer contract these new bindings use From c39da9b526d55475b496864c7de4105de4142476 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 15:52:20 -0700 Subject: [PATCH 06/12] fix(libmoq): serialize JSON values before taking the state lock Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/consume.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 08adfcff3f2..249f1381435 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -1010,8 +1010,10 @@ impl Consume { }, }; - // Hold the lock only to buffer the value; release it before the callback. - let value_id = State::lock().consume.json_value.insert(JsonValue::new(value)?)?; + // Serialize before locking, hold the lock only to buffer the value, and release it + // before the callback. + let value = JsonValue::new(value)?; + let value_id = State::lock().consume.json_value.insert(value)?; callback.call(Ok(value_id)); } } @@ -1077,7 +1079,8 @@ impl Consume { }, }; - let value_id = State::lock().consume.json_value.insert(JsonValue::new(value)?)?; + let value = JsonValue::new(value)?; + let value_id = State::lock().consume.json_value.insert(value)?; callback.call(Ok(value_id)); } } From 0b3dfd414e2177bd9be232c57507d4fd690745ab Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 17 Oct 2026 20:59:55 -0700 Subject: [PATCH 07/12] fix(data): adapt new stats readers, document interim microsecond rounding, add MIGRATE entries Co-Authored-By: Claude Opus 5.5 --- cpp/moq/MIGRATE.md | 6 ++++++ dart/moq/MIGRATE.md | 6 ++++++ dart/moq/lib/json.dart | 4 +++- go/wrapper/MIGRATE.md | 6 ++++++ go/wrapper/json/json.go | 4 +++- kt/moq/MIGRATE.md | 6 ++++++ .../jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt | 5 ++++- py/moq-rs/MIGRATE.md | 6 ++++++ py/moq-rs/moq/json.py | 4 +++- rs/hang/src/catalog/consumer.rs | 6 +----- rs/moq-c/MIGRATE.md | 6 ++++++ rs/moq-c/src/api.rs | 3 ++- rs/moq-ffi/MIGRATE.md | 6 ++++++ rs/moq-ffi/src/json.rs | 2 ++ rs/moq-flate/MIGRATE.md | 5 +++++ rs/moq-json/MIGRATE.md | 5 +++++ rs/moq-mux/MIGRATE.md | 5 +++++ rs/moq-mux/src/catalog/hang/consumer.rs | 6 +----- rs/moq-stats/src/consume.rs | 2 +- rs/moq-stats/src/produce.rs | 10 ++++++---- swift/MIGRATE.md | 6 ++++++ swift/Sources/Moq/Json.swift | 2 ++ 22 files changed, 91 insertions(+), 20 deletions(-) diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index 63ce5456a5b..8c706b63913 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 +### JSON consumers return `JsonValue` (#5677) + +`JsonSnapshotConsumer::next()` and `JsonStreamConsumer::next()` return `JsonValue` (`value`, `timestamp_us`) instead of a string; `timestamp_us` is empty when untimed. +A snapshot consumer's `next()` now yields every state in order; call `latest()` for the newest. +Timestamps are rounded down to a whole microsecond. + ### Subscriptions carry group ranges (#5732) `Subscription::group_start` and `group_end` are replaced by `ranges`, a list of `Range` whose ends are an `Anchor` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 965ea3b1cd6..e9cabaf1f96 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 +### JSON consumers return `Value` (#5677) + +`SnapshotConsumer.next()` and `StreamConsumer.next()` return `Value` (`value`, `timestampUs`) instead of a string; `timestampUs` is null when untimed. +A snapshot consumer's `next()` now yields every state in order; call `latest()` for the newest. +Timestamps are rounded down to a whole microsecond. + ### Subscriptions carry group ranges (#5732) `Subscription.groupStart` and `groupEnd` are replaced by `ranges`, a list of `Range` whose ends are an `Anchor` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/dart/moq/lib/json.dart b/dart/moq/lib/json.dart index bf053621b65..7c477812460 100644 --- a/dart/moq/lib/json.dart +++ b/dart/moq/lib/json.dart @@ -38,5 +38,7 @@ typedef StreamConsumer = MoqJsonStreamConsumer; typedef StreamConfig = MoqJsonStreamConfig; /// A JSON string read from a track, with the timestamp of the frame that -/// carried it in microseconds, or null when untimed. +/// carried it in microseconds, or null when untimed. Rounded down to a whole +/// microsecond, so a tick on a track like 90 kHz may not survive a read and +/// republish exactly. typedef Value = MoqJsonValue; diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 8689478554b..f77e5285566 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 +### 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. +A snapshot consumer's `Next` now yields every state in order; call `Latest(ctx)` for the newest. +Timestamps are rounded down to a whole microsecond. + ### Subscriptions carry group ranges (#5732) `Subscription.GroupStart` and `GroupEnd` are replaced by `Ranges`, a slice of `Range` whose ends are `AnchorAbsolute` or `AnchorRelative` (an absolute group and frame, or a count of groups before the latest), and an optional `Order`. diff --git a/go/wrapper/json/json.go b/go/wrapper/json/json.go index 20121e659e8..9a1293db8fd 100644 --- a/go/wrapper/json/json.go +++ b/go/wrapper/json/json.go @@ -156,7 +156,9 @@ func (p *StreamProducer) Finish() error { type Timed struct { // Value is the JSON document. Value stdjson.RawMessage - // Timestamp is on the broadcast clock, or nil on an untimed track. + // Timestamp is on the broadcast clock, or nil on an untimed track. It is rounded down + // to a whole microsecond, so a tick on a track like 90 kHz may not survive a read and + // republish exactly. Timestamp *time.Duration } diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 2552722b9e0..4456c24c903 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 +### 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)`. +A snapshot consumer's `next()` now yields every state in order; call `latest()` for the newest. +Timestamps are rounded down to a whole microsecond. + ### Subscriptions carry group ranges (#5732) `Subscription.groupStart` and `groupEnd` are replaced by `ranges`, a list of `Range` whose ends are `AnchorAbsolute` or `AnchorRelative` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt index cdfa8de663f..7f471593f8c 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt @@ -35,7 +35,10 @@ typealias StreamConfig = uniffi.moq.MoqJsonStreamConfig /** A JSON string and the timestamp of the frame that carried it. */ typealias Value = uniffi.moq.MoqJsonValue -/** The frame's timestamp on the broadcast clock, or null when untimed. */ +/** + * The frame's timestamp on the broadcast clock, or null when untimed. Rounded down to a whole + * microsecond, so a tick on a track like 90 kHz may not survive a read and republish exactly. + */ val Value.timestamp: Duration? get() = timestampUs?.toLong()?.microseconds diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 980942e7d1e..423d8960ca1 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 +### 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. +Iterating a snapshot consumer now yields every state in order; `await consumer.latest()` returns the newest. +Timestamps are rounded down to a whole microsecond. + ### Subscriptions carry group ranges (#5732) `Subscription.group_start` and `group_end` are replaced by `ranges`, a list of `Range` whose ends are an `Anchor` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/py/moq-rs/moq/json.py b/py/moq-rs/moq/json.py index 3155a427d7b..7de5bdd4ce4 100644 --- a/py/moq-rs/moq/json.py +++ b/py/moq-rs/moq/json.py @@ -34,7 +34,9 @@ class Timed: """A parsed JSON value and the timestamp of the frame that carried it. - ``timestamp`` is on the broadcast clock, and ``None`` on an untimed track. + ``timestamp`` is on the broadcast clock, and ``None`` on an untimed track. It is + rounded down to a whole microsecond, so a tick on a track like 90 kHz may not + survive a read and republish exactly. """ value: Any diff --git a/rs/hang/src/catalog/consumer.rs b/rs/hang/src/catalog/consumer.rs index 345207ee308..b7d57aef55e 100644 --- a/rs/hang/src/catalog/consumer.rs +++ b/rs/hang/src/catalog/consumer.rs @@ -39,14 +39,10 @@ impl Consumer { /// Poll for the next catalog update. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { -<<<<<<< HEAD - let catalog = ready!(self.inner.poll_latest(waiter)) + let value = ready!(self.inner.poll_latest(waiter)) .map_err(|err| crate::Error::Json(err.to_string()))? .map(|value| value.value); -======= - let value = ready!(self.inner.poll_next(waiter)).map_err(|err| crate::Error::Json(err.to_string()))?; let catalog = value.map(Catalog::from_value).transpose()?; ->>>>>>> origin/main if let Some(catalog) = catalog.as_ref() { catalog.check_renditions()?; check_resolvable(&self.base, catalog)?; diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 3168e31494e..04518ae378c 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 +### JSON values carry their timestamp (#5677) + +`moq_json_value` gains `timestamp_us` and `timestamp_present`, which changes its ABI. +A snapshot consumer's callback now fires for each state in order instead of only the newest. +Timestamps are rounded down to a whole microsecond. + ### Subscriptions carry group ranges (#5732) `moq_subscription`'s `group_start`, `group_end`, and their `_present` flags are replaced by `ranges` and `ranges_len`, an array of `moq_range` whose ends are `moq_anchor`s, and `order` (`moq_order`), which changes its ABI. diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 19dcc60aaea..07dabab1c8f 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -458,7 +458,8 @@ pub struct moq_json_value { pub json_len: usize, /// The timestamp of the frame that carried the value in microseconds, or 0 when - /// `timestamp_present` is false. + /// `timestamp_present` is false. Rounded down to a whole microsecond, so a tick on a track + /// like 90 kHz may not survive a read and republish exactly. pub timestamp_us: u64, /// Whether the frame carries a timestamp. A value read from an untimed track does not. diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index e75eb72a719..b3b08889d13 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 +### 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. +Snapshot `next` now yields every state in order; call the new `MoqJsonSnapshotConsumer::latest` for the old collapse-to-newest read. +Timestamps are rounded down to a whole microsecond. + ### Subscriptions carry group ranges (#5732) `MoqSubscription::group_start` and `group_end` are replaced by `ranges`, a list of `MoqRange { start, end }` whose ends are `MoqAnchor::Absolute { group, frame }` or `MoqAnchor::Relative { groups }`, and an optional `order`. diff --git a/rs/moq-ffi/src/json.rs b/rs/moq-ffi/src/json.rs index 3fa697d56a6..82353be924f 100644 --- a/rs/moq-ffi/src/json.rs +++ b/rs/moq-ffi/src/json.rs @@ -172,6 +172,8 @@ pub struct MoqJsonValue { /// The JSON document. pub value: String, /// The frame's timestamp on the broadcast clock in microseconds, or null when untimed. + /// Rounded down to a whole microsecond, so a tick on a track like 90 kHz may not survive a + /// read and republish exactly. pub timestamp_us: Option, } diff --git a/rs/moq-flate/MIGRATE.md b/rs/moq-flate/MIGRATE.md index a9fdeef918e..97a02c1bc80 100644 --- a/rs/moq-flate/MIGRATE.md +++ b/rs/moq-flate/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Consumers return `Timed` (#5677) + +Snapshot and stream `next` / `poll_next` return `moq_net::Timed`: read `.value` for the payload and `.at` for the frame's exact timestamp, `None` when untimed. +Snapshot `next` now yields every state in order inside the subscription's `max_delay`; call `latest` / `poll_latest` for the old collapse-to-newest read. + ### moq-binary is moq-flate The opaque `snapshot` and `stream` tracks moved beside the codec, so `moq_binary::X` is `moq_flate::X`; the wire is unchanged. diff --git a/rs/moq-json/MIGRATE.md b/rs/moq-json/MIGRATE.md index ba870ca1ad8..0c6a4dd418f 100644 --- a/rs/moq-json/MIGRATE.md +++ b/rs/moq-json/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Consumers return `Timed` (#5677) + +Snapshot and stream `next` / `poll_next` return `moq_net::Timed`: read `.value` for the value and `.at` for the frame's exact timestamp, `None` when untimed. +Snapshot `next` now yields every state in order inside the subscription's `max_delay`; call `latest` / `poll_latest` for the old collapse-to-newest read. + ### Demand is read through `demand()` Call `producer.demand().is_used()` instead of `producer.is_used()`. diff --git a/rs/moq-mux/MIGRATE.md b/rs/moq-mux/MIGRATE.md index cf7adb6888e..2d01cdf2a80 100644 --- a/rs/moq-mux/MIGRATE.md +++ b/rs/moq-mux/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### 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`. +On a snapshot track `next` yields every state; call `latest` / `poll_latest` for the newest. `latest` on a stream track fails with `UnsupportedMode`. + ### `fmp4::Wire::new` refuses a track that isn't audio or video (#5815) `container::fmp4::Wire::new` returns `Result`, as `Wire::from_init` already did, and refuses any other handler instead of treating it as data. diff --git a/rs/moq-mux/src/catalog/hang/consumer.rs b/rs/moq-mux/src/catalog/hang/consumer.rs index 9c66ed6a8db..7f259270e1e 100644 --- a/rs/moq-mux/src/catalog/hang/consumer.rs +++ b/rs/moq-mux/src/catalog/hang/consumer.rs @@ -66,12 +66,8 @@ impl Consumer { /// that from the operator while the missing rendition surfaces much later as a track that /// never fills. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { -<<<<<<< HEAD - let catalog = ready!(self.inner.poll_latest(waiter))?.map(|value| value.value); -======= - let value = ready!(self.inner.poll_next(waiter))?; + let value = ready!(self.inner.poll_latest(waiter))?.map(|value| value.value); let catalog = value.map(Catalog::from_value).transpose()?; ->>>>>>> origin/main if let Some(catalog) = catalog.as_ref() { catalog.check_renditions()?; check_resolvable(&self.base, catalog)?; diff --git a/rs/moq-stats/src/consume.rs b/rs/moq-stats/src/consume.rs index 83729c36e5e..c4be8d72680 100644 --- a/rs/moq-stats/src/consume.rs +++ b/rs/moq-stats/src/consume.rs @@ -170,7 +170,7 @@ impl Prefix { /// [`FrameTooLarge`](moq_net::Error::FrameTooLarge) once the frame outgrows /// the cache. pub async fn next(&mut self) -> Result> { - Ok(self.inner.next().await?) + Ok(self.inner.latest().await?.map(|value| value.value)) } } diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index 41d494e5e61..641470dbfb1 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -1734,7 +1734,7 @@ mod tests { drop(group); drop(cursor); let mut reader = moq_json::snapshot::Consumer::::new(subscriber, config.clone()); - let initial = pumping(&mut drain, reader.next()).await.unwrap().unwrap(); + let initial = pumping(&mut drain, reader.next()).await.unwrap().unwrap().value; assert_eq!( (initial[""].wire_tx, initial[""].wire_rx), (0, 0), @@ -1743,14 +1743,14 @@ mod tests { store(reading + delta); drain.collect(); drain.publish(Instant::now()); - let next = pumping(&mut drain, reader.next()).await.unwrap().unwrap(); + let next = pumping(&mut drain, reader.next()).await.unwrap().unwrap().value; assert_eq!((next[""].wire_tx, next[""].wire_rx), (delta, delta * 2)); if compressed { // Cross the codec's group budget naturally, leaving a completed // group cached when unsubscribe aborts the still-open last group. for extra in 1..=300 { store(reading + delta + extra); - let next = pumping(&mut drain, reader.next()).await.unwrap().unwrap(); + let next = pumping(&mut drain, reader.next()).await.unwrap().unwrap().value; assert_eq!( (next[""].wire_tx, next[""].wire_rx), (delta + extra, (delta + extra) * 2) @@ -1829,7 +1829,9 @@ mod tests { }; let mut plain = read("t.json", moq_json::Compression::None); let mut compressed = read("t.json.z", moq_json::Compression::Deflate); - let next = |reader: &mut moq_json::snapshot::Consumer| reader.next().now_or_never().unwrap(); + let next = |reader: &mut moq_json::snapshot::Consumer| { + reader.next().now_or_never().unwrap().map(|value| value.map(|value| value.value)) + }; let small = Value::from([("small".to_string(), 1)]); assert_eq!(next(&mut plain).unwrap(), Some(small.clone())); assert_eq!(next(&mut compressed).unwrap(), Some(small)); diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 9227b0b2d5e..8bb0474222d 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 +### 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. +A snapshot consumer's `next()` now yields every state in order; call `latest()` for the newest. +Timestamps are rounded down to a whole microsecond. + ### Subscriptions carry group ranges (#5732) `Subscription.groupStart` and `groupEnd` are replaced by `ranges`, a list of `Subscription.Range` whose ends are a `Subscription.Anchor` (`.absolute(group:frame:)` or `.relative(groups:)`), and an optional `order`. diff --git a/swift/Sources/Moq/Json.swift b/swift/Sources/Moq/Json.swift index 446108bb59e..e6c3c6a9d7a 100644 --- a/swift/Sources/Moq/Json.swift +++ b/swift/Sources/Moq/Json.swift @@ -17,6 +17,8 @@ extension Json { /// The decoded value. public let value: Value /// The frame's timestamp on the broadcast clock in microseconds, or `nil` when untimed. + /// Rounded down to a whole microsecond, so a tick on a track like 90 kHz may not survive + /// a read and republish exactly. public let timestampUs: UInt64? /// Pair `value` with its frame timestamp. From 723d793273b6072b31621078b73bead517bdb0fb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 17 Oct 2026 21:02:07 -0700 Subject: [PATCH 08/12] style: format stats test Co-Authored-By: Claude Opus 5.5 --- rs/moq-stats/src/produce.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index 641470dbfb1..9c8a8ba347f 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -1830,7 +1830,11 @@ mod tests { let mut plain = read("t.json", moq_json::Compression::None); let mut compressed = read("t.json.z", moq_json::Compression::Deflate); let next = |reader: &mut moq_json::snapshot::Consumer| { - reader.next().now_or_never().unwrap().map(|value| value.map(|value| value.value)) + reader + .next() + .now_or_never() + .unwrap() + .map(|value| value.map(|value| value.value)) }; let small = Value::from([("small".to_string(), 1)]); assert_eq!(next(&mut plain).unwrap(), Some(small.clone())); From 64f5d27161f6c1bf3a415d32d9f4e4d379722676 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 17 Oct 2026 21:41:44 -0700 Subject: [PATCH 09/12] docs(lib): document timed JSON reads in every binding guide Co-Authored-By: Claude Opus 5.5 --- doc/lib/c/index.md | 1 + doc/lib/dart/index.md | 2 ++ doc/lib/go/index.md | 2 +- doc/lib/kt/index.md | 2 ++ doc/lib/py/index.md | 2 ++ doc/lib/swift/index.md | 2 ++ 6 files changed, 10 insertions(+), 1 deletion(-) diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index c022beb9aaa..7d5e9d5ee61 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -63,6 +63,7 @@ inside. The header is the reference: each function carries a doc comment. - **Zeroed config is the default.** A zeroed `moq_client_config` means the default for every field, so new fields don't break callers. A field whose default isn't zero has a `has_*` flag: `backoff_timeout_us = 0` means "retry forever" only with `has_backoff_timeout = true`. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `moq_publish_media_cut`: after every frame, or at a segment cadence to align with video. - **Live encoder timing.** After writing a frame you encoded yourself, call `moq_publish_media_flush` with the same timestamp so the catalog advertises your jitter. Skip it for file and network imports. On a seek or pause, call `moq_publish_media_discontinuity`, then keep timestamps moving forward and resume video on a keyframe. +- **JSON values.** `moq_consume_json_value` fills `moq_json_value` with the document and its frame's `timestamp_us`, rounded down to a whole microsecond; `timestamp_present` is false on an untimed track. A snapshot consumer's callback fires for each state in order. - **Decoded frames hold decoder buffers.** Free each frame id with `moq_decode_video_frame_free` promptly, or the decoder stalls. - **Stats.** `moq_session_stats()` fills `moq_connection_stats` with `rtt_us`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). Each has a `_valid` flag, false when the transport does not report it, which is not the same as zero. `moq_session_snapshot()` adds the negotiated `protocol`, and while a session reconnects `moq_session_failure()` returns why, with the reason in `moq_error()`. Hold the handle returned by `moq_session_recv_bandwidth()` to request receive estimates. `moq_bandwidth_consumer_peek()` reads the estimate and `moq_bandwidth_consumer_changed()` waits for a change. `moq_bandwidth_consumer_close()` releases demand; the last consumer stops PROBE. Stats snapshots do not request it. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index c797e547eaa..9971601fdbb 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -19,6 +19,8 @@ and a `Named` or `Requested` target. `CatalogProducer(broadcast: ...)` owns cata and sections; it holds the broadcast weakly and fails after the broadcast closes. `package:moq/flate.dart` exposes opaque `SnapshotProducer` / `StreamProducer` with an optional `timestampUs`. A missing data timestamp stays untimed; the library never substitutes zero or now. +`package:moq/json.dart` consumers return `Value` (`value`, `timestampUs`) from `next()`; a snapshot consumer +yields each state in order, and `latest()` skips to the newest. Read timestamps are rounded down to a whole microsecond. Raw tracks with a `null` timescale are untimed on both publish and receive. Whole-frame media writes require caller time; container imports keep their own timeline handling. Timed raw/data tracks require caller-supplied timestamps. Use `CatalogProducer.now()` to stamp a locally diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 1ba7b39bb6b..5507a25a4bb 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -22,7 +22,7 @@ Raw tracks with a `nil` timescale are untimed on both publish and receive. `moq.dev/moq/flate` publishes opaque snapshot and stream payloads; its `Update` / `Append` and the JSON producers take a final `*time.Duration` timestamp argument (`nil` for untimed). JSON consumers return a `*Timed` holding the value and that timestamp; `SnapshotConsumer.Latest` -skips to the newest state where `Next` yields each one. +skips to the newest state where `Next` yields each one. Read timestamps are rounded down to a whole microsecond. Whole-frame media writes require caller time; container imports keep their own timeline handling. Timed raw/data tracks require caller-supplied timestamps. Use `CatalogProducer.Now()` to stamp a locally created event on the broadcast clock; reading it fixes that clock for later media producers. diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 581c0f5a6a3..d54bf5cb761 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -18,6 +18,8 @@ x86\_64) and desktop JVM (Linux x86\_64/aarch64, macOS arm64, Windows x64). `CatalogProducer(broadcast)` holds its broadcast weakly and fails with `Closed` after it closes. `dev.moq.flate.SnapshotProducer` / `StreamProducer` publish opaque bytes with an optional `timestampUs`. A missing data timestamp stays untimed; the library never substitutes zero or now. +`dev.moq.json` consumers' `values()` emits `Value` (`value`, `timestamp`) and `valuesAs()` emits `Timed`; +a snapshot consumer yields each state in order, and `latest()` skips to the newest. Read timestamps are rounded down to a whole microsecond. Raw tracks with a `null` timescale are untimed on both publish and receive. Whole-frame media writes require caller time; container imports keep their own timeline handling. Timed raw/data tracks require caller-supplied timestamps. Use `CatalogProducer.now()` to stamp a locally diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index fb21d81ace4..929c4d6ea9c 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -23,6 +23,8 @@ omitting its timebase keeps an independent offset. A timebase belongs to its bro and does not keep it open. `MediaFrame.timestamp`, media writes, and `flush` use `datetime.timedelta`. A missing data timestamp stays untimed; the library never substitutes zero or now. +JSON consumers yield `moq.json.Timed(value, timestamp)`; a snapshot consumer yields each state in order, +and `await consumer.latest()` skips to the newest. Read timestamps are rounded down to a whole microsecond. Raw tracks with a `None` timescale are untimed on both publish and receive. `moq.flate.SnapshotProducer` / `StreamProducer` publish opaque payloads; their `update` / `append` and the JSON producers accept `timestamp=timedelta(...)` (`None` for untimed). diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index cba77507d7d..d1d0affc015 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -19,6 +19,8 @@ updates catalog properties and sections without keeping the broadcast open. Its writes fail after closing or releasing the broadcast. `Flate.SnapshotProducer` / `StreamProducer` publish opaque bytes with an optional `timestampUs`. A missing data timestamp stays untimed; the library never substitutes zero or now. +`Json` consumers yield `Json.Timed` (`value`, `timestampUs`); a snapshot consumer yields each state in order, +and `latest()` skips to the newest. Read timestamps are rounded down to a whole microsecond. Raw tracks with a `nil` timescale are untimed on both publish and receive. Whole-frame media writes require caller time; container imports keep their own timeline handling. Timed raw/data tracks require caller-supplied timestamps. Use `Media.CatalogProducer.now()` to stamp a locally From 7703f15df46a4daae6005fc87a45a783e4862ce6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 22:41:01 -0700 Subject: [PATCH 10/12] style: format bounds getter Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 5f03500a9ef..1d9213668fd 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5677,7 +5677,10 @@ impl Subscriber { /// Pass a modified copy back to [`Self::set_groups`] to move one end and keep the other. pub fn bounds(&self) -> (Bound, Bound) { let bounds = self.local_bounds(); - (Bound::Included(bounds.start), bounds.end.map_or(Bound::Unbounded, Bound::Excluded)) + ( + Bound::Included(bounds.start), + bounds.end.map_or(Bound::Unbounded, Bound::Excluded), + ) } /// The local bounds `largest` reports within. From 710c0251a994c84b5105dedc5cdb1eb42f34b5fa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 02:06:41 -0700 Subject: [PATCH 11/12] test(kt): read the timed value in the flow cancellation test Co-Authored-By: Claude Opus 5.5 --- kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index 57747bd7111..691a8799972 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -451,7 +451,7 @@ class SmokeTest { val consumer = SnapshotConsumer(broadcast.consume().subscribeTrack("status", null), config) while (demand.wantedChanged() == null) {} // `first` cancels the collection, which must release the subscription without an explicit cancel. - assertEquals(Status(state = "live"), consumer.valuesAs().first()) + assertEquals(Status(state = "live"), consumer.valuesAs().first().value) while (demand.wantedChanged() != null) {} } } From 4cf13a98b01d9ace1c75bdcb7f3012bccc5aaa72 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 02:47:24 -0700 Subject: [PATCH 12/12] fix(data): skip to the newest group the reader has, ignoring datagrams A datagram shares the sequence space and advances the track's largest, so latest() raised the floor past every group and stalled. Drain the groups the reader already holds and keep the newest instead: it ignores datagrams and keeps a caller's cap without touching the bounds, so the moq-net bounds() getter has no consumer and goes. Co-Authored-By: Claude Opus 5.5 --- doc/lib/c/index.md | 2 +- quest/m1/public-model/serve.md | 3 ++ rs/moq-flate/src/snapshot/consumer.rs | 53 ++++++++++++++++----------- rs/moq-json/src/snapshot/consumer.rs | 53 ++++++++++++++++----------- rs/moq-net/src/model/track.rs | 39 +------------------- 5 files changed, 68 insertions(+), 82 deletions(-) diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 2e21bbaa97e..07eb92bc5cd 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -64,7 +64,7 @@ inside. The header is the reference: each function carries a doc comment. - **Zeroed config is the default.** A zeroed `moq_client_config` means the default for every field, so new fields don't break callers. A field whose default isn't zero has a `has_*` flag: `backoff_timeout_us = 0` means "retry forever" only with `has_backoff_timeout = true`. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `moq_publish_media_cut`: after every frame, or at a segment cadence to align with video. - **Live encoder timing.** After writing a frame you encoded yourself, call `moq_publish_media_flush` with the same timestamp so the catalog advertises your jitter. Skip it for file and network imports. On a seek or pause, call `moq_publish_media_discontinuity`, then keep timestamps moving forward and resume video on a keyframe. -- **JSON values.** `moq_consume_json_value` fills `moq_json_value` with the document and its frame's `timestamp_us`, rounded down to a whole microsecond; `timestamp_present` is false on an untimed track. A snapshot consumer's callback fires for each state in order. +- **JSON values.** `moq_consume_json_value` fills `moq_json_value` with the document and its frame's `timestamp_us`, rounded down to a whole microsecond; `timestamp_present` is false on an untimed track. A snapshot consumer's callback fires for each state in order, but a group superseded before it is read is skipped. - **Decoded frames hold decoder buffers.** Free each frame id with `moq_decode_video_frame_free` promptly, or the decoder stalls. - **Stats.** `moq_session_stats()` fills `moq_connection_stats` with `rtt_us`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). Each has a `_valid` flag, false when the transport does not report it, which is not the same as zero. `moq_session_snapshot()` adds the negotiated `protocol`, and while a session reconnects `moq_session_failure()` returns why, with the reason in `moq_error()`. Hold the handle returned by `moq_session_recv_bandwidth()` to request receive estimates. `moq_bandwidth_consumer_peek()` reads the estimate and `moq_bandwidth_consumer_changed()` waits for a change. `moq_bandwidth_consumer_close()` releases demand; the last consumer stops PROBE. Stats snapshots do not request it. - **Demand.** A watcher from `moq_publish_track_demand()` (or the media, video, and audio variants) calls back `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED`, again whenever the wanted group moves. `moq_publish_demand_wanted()` reads that group: start a new one once it passes your largest. Set `refresh` in `moq_track_info` to have a joining subscriber ask for a new group once the newest has grown past `refresh` times its first frame. diff --git a/quest/m1/public-model/serve.md b/quest/m1/public-model/serve.md index 7f30db46b6c..e08d6b52774 100644 --- a/quest/m1/public-model/serve.md +++ b/quest/m1/public-model/serve.md @@ -61,6 +61,9 @@ Rust items (`rs/moq-net/src/model/`), as they stood at b4eab0db1: `setGroups({ start: { included: latest.group } })`, whose omitted end removes the caller's cap. The rebinding must keep that cap; land it with a regression test where a capped snapshot reader stays below its cap. + A datagram also advances `largest()` past every group, stranding the + read. The Rust consumers instead drain the groups the reader already + has and keep the newest (#5677), which needs no floor at all. - `rs/moq-hls` export narrows a fetched group with `set_frames(..end)`. - Readers that set a floor once right after `poll_largest` (moq-audio, moq-video, moq-cli play, moq-json window, moq-mux) fit the builder. diff --git a/rs/moq-flate/src/snapshot/consumer.rs b/rs/moq-flate/src/snapshot/consumer.rs index f5311a5e55c..0fea756618d 100644 --- a/rs/moq-flate/src/snapshot/consumer.rs +++ b/rs/moq-flate/src/snapshot/consumer.rs @@ -1,6 +1,6 @@ //! Consuming opaque values and their timestamps from a track. -use std::{ops::Bound, task::Poll}; +use std::task::Poll; use bytes::Bytes; @@ -16,8 +16,6 @@ pub struct Consumer { /// normally one frame, but the window is per group either way. flate: Option, compression: bool, - /// The newest group the bounded largest reported, the target of `latest`. - largest: Option, } impl Consumer { @@ -30,7 +28,6 @@ impl Consumer { group: None, flate: None, compression: config.compression.is_deflate(), - largest: None, } } @@ -53,8 +50,7 @@ impl Consumer { kio::wait(|waiter| self.poll_latest(waiter)).await } - /// Poll for the newest state within the track's group bounds, discarding superseded groups - /// and intermediate yields. + /// Poll for the newest state, discarding superseded groups and intermediate yields. /// /// Frames inside the newest group are still decoded in order. What this skips is gone; /// a subsequent `next` resumes after it. An untimed frame keeps `at: None`. @@ -64,22 +60,12 @@ impl Consumer { fn poll_read(&mut self, waiter: &kio::Waiter, skip: bool) -> Poll>>> { if skip { - // Bounded by the reader's cap, so a cap set before this consumer took the track holds. - if let Poll::Ready(Some(largest)) = self.track.poll_largest(waiter) { - self.largest = Some(largest.group); - } - if let Some(largest) = self.largest { - if self.group.as_ref().is_some_and(|group| group.sequence < largest) { - self.group = None; - } - // Raise only the floor: an omitted end would drop the cap. Raising it only when - // it moves keeps the bounds change from waking the parked largest poll again. - if self.group.is_none() - && let (Bound::Included(start), end) = self.track.bounds() - && start < largest - { - self.track.set_groups((Bound::Included(largest), end)); - } + // Jump to the newest group the reader already has; a newer one supersedes the rest. + // Taking groups, not the track's largest position, ignores datagrams (which share + // the sequence space) and stays inside any cap the caller set on the reader. + while let Poll::Ready(Some(group)) = self.track.poll_next_group(waiter)? { + self.group = Some(group); + self.flate = self.compression.then(crate::Decoder::new); } } @@ -259,4 +245,27 @@ mod timestamps { assert_eq!(value.value[0], 4, "latest crossed the cap"); assert!(consumer.poll_next(&waiter).is_pending()); } + + #[test] + fn latest_ignores_datagrams() { + let track = moq_net::broadcast::Info::new() + .produce() + .create_track("data", moq_net::track::Info::default()) + .unwrap(); + let mut raw = track.clone(); + let subscriber = track.subscribe(None); + let config = crate::snapshot::Config::default(); + let mut producer = crate::snapshot::Producer::new(track, config.clone()); + let mut consumer = Consumer::new(subscriber, config); + let waiter = kio::Waiter::noop(); + for n in 0..2u8 { + producer.update(Bytes::from(vec![n; 32])).unwrap(); + } + // A datagram shares the sequence space, so it outnumbers every group. + raw.append_datagram(None, &b"noise"[..]).unwrap(); + let Poll::Ready(Ok(Some(value))) = consumer.poll_latest(&waiter) else { + panic!("a datagram hid the latest state") + }; + assert_eq!(value.value[0], 1); + } } diff --git a/rs/moq-json/src/snapshot/consumer.rs b/rs/moq-json/src/snapshot/consumer.rs index ac15983aa72..e630536ce56 100644 --- a/rs/moq-json/src/snapshot/consumer.rs +++ b/rs/moq-json/src/snapshot/consumer.rs @@ -1,6 +1,6 @@ //! Consuming a JSON value from a track: a [`Decoder`] plus the track it reads from. -use std::{ops::Bound, task::Poll}; +use std::task::Poll; use serde::de::DeserializeOwned; @@ -34,8 +34,6 @@ pub struct Consumer { group: Option, decoder: Decoder, frames_read: usize, - /// The newest group the bounded largest reported, the target of `latest`. - largest: Option, } impl Consumer { @@ -49,7 +47,6 @@ impl Consumer { group: None, decoder: Decoder::new(config), frames_read: 0, - largest: None, } } @@ -78,8 +75,7 @@ impl Consumer { kio::wait(|waiter| self.poll_latest(waiter)).await } - /// Poll for the newest state within the track's group bounds, discarding superseded groups - /// and intermediate yields. + /// Poll for the newest state, discarding superseded groups and intermediate yields. /// /// Frames inside the newest group are still decoded in order. What this skips is gone; /// a subsequent `next` resumes after it. An untimed frame keeps `at: None`. @@ -89,22 +85,12 @@ impl Consumer { fn poll_read(&mut self, waiter: &kio::Waiter, skip: bool) -> Poll>>> { if skip { - // Bounded by the reader's cap, so a cap set before this consumer took the track holds. - if let Poll::Ready(Some(largest)) = self.track.poll_largest(waiter) { - self.largest = Some(largest.group); - } - if let Some(largest) = self.largest { - if self.group.as_ref().is_some_and(|group| group.sequence < largest) { - self.group = None; - } - // Raise only the floor: an omitted end would drop the cap. Raising it only when - // it moves keeps the bounds change from waking the parked largest poll again. - if self.group.is_none() - && let (Bound::Included(start), end) = self.track.bounds() - && start < largest - { - self.track.set_groups((Bound::Included(largest), end)); - } + // Jump to the newest group the reader already has; a newer one supersedes the rest. + // Taking groups, not the track's largest position, ignores datagrams (which share + // the sequence space) and stays inside any cap the caller set on the reader. + while let Poll::Ready(Some(group)) = self.track.poll_next_group(waiter)? { + self.group = Some(group); + self.frames_read = 0; } } @@ -323,4 +309,27 @@ mod timestamps { assert_eq!(value.value["n"], 4, "latest crossed the cap"); assert!(consumer.poll_next(&waiter).is_pending()); } + + #[test] + fn latest_ignores_datagrams() { + let track = moq_net::broadcast::Info::new() + .produce() + .create_track("data", moq_net::track::Info::default()) + .unwrap(); + let mut raw = track.clone(); + let subscriber = track.subscribe(None); + let mut producer = crate::snapshot::Producer::new(track, crate::snapshot::Config::default()); + let mut consumer = Consumer::::new(subscriber, Default::default()); + let waiter = kio::Waiter::noop(); + for n in 0..2 { + producer.update(&serde_json::json!({ "n": n })).unwrap(); + producer.cut().unwrap(); + } + // A datagram shares the sequence space, so it outnumbers every group. + raw.append_datagram(None, &b"noise"[..]).unwrap(); + let Poll::Ready(Ok(Some(value))) = consumer.poll_latest(&waiter) else { + panic!("a datagram hid the latest state") + }; + assert_eq!(value.value["n"], 1); + } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 89ce7003a94..24d9ea02c76 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -6031,19 +6031,8 @@ impl Subscriber { self.largest.bounds_moved(); } - /// This reader's group bounds as [`Self::set_groups`] set them: an inclusive floor and an exclusive cap. - /// - /// Pass a modified copy back to [`Self::set_groups`] to move one end and keep the other. - pub fn bounds(&self) -> (Bound, Bound) { - let bounds = self.local_bounds(); - ( - Bound::Included(bounds.start), - bounds.end.map_or(Bound::Unbounded, Bound::Excluded), - ) - } - /// The local bounds `largest` reports within. - fn local_bounds(&self) -> Bounds { + fn bounds(&self) -> Bounds { match &self.inner { Inner::Plain(cursor) => Bounds { start: cursor.min_sequence, @@ -6091,7 +6080,7 @@ impl Subscriber { if self.largest.ended { return Poll::Ready(None); } - let bounds = self.local_bounds(); + let bounds = self.bounds(); let poll = self.poll_largest_within(bounds, waiter); // Only a parked poll waits on the bounds; a finished one is not woken by them. match poll { @@ -6268,11 +6257,6 @@ impl Ordered { self.inner.set_groups(groups); } - /// This reader's group bounds; see [`Subscriber::bounds`]. - pub fn bounds(&self) -> (Bound, Bound) { - self.inner.bounds() - } - /// Create a handle for updating this subscriber's delivery preferences without /// borrowing the read cursor. pub fn control(&self) -> Control { @@ -6963,25 +6947,6 @@ mod test { ); } - /// The bounds getter reads back what `set_groups` set, so a caller can move one end and keep the other. - #[test] - fn bounds_read_back_what_set_groups_set() { - let producer = track_producer("test", Info::default()); - let mut reader = producer.subscribe(replay()); - assert_eq!(reader.bounds(), (Bound::Included(0), Bound::Unbounded)); - - reader.set_groups(2..5); - assert_eq!(reader.bounds(), (Bound::Included(2), Bound::Excluded(5))); - - let (_, end) = reader.bounds(); - reader.set_groups((Bound::Included(4), end)); - assert_eq!(reader.bounds(), (Bound::Included(4), Bound::Excluded(5))); - - let mut ordered = reader.ordered(); - ordered.set_groups(..=6); - assert_eq!(ordered.bounds(), (Bound::Included(4), Bound::Excluded(7))); - } - /// Once the stream ended, widening the bounds over a largest it never reported does not /// bring it back. #[test]