From 31ecc04cbbabef4f167c077e4f8df55867a0e99f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 21:40:27 -0700 Subject: [PATCH 01/48] quest: claim quest/m1/subscribe-ranges/dynamic Co-Authored-By: Claude Opus 5.5 From 931295836e6dce358b2ab43908da997f1fcb07c4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 22:15:11 -0700 Subject: [PATCH 02/48] wip: fills replace group requests in moq-net --- rs/moq-net/src/ietf/publisher.rs | 14 +- rs/moq-net/src/ietf/subscriber.rs | 62 ++- rs/moq-net/src/lite/publisher.rs | 8 +- rs/moq-net/src/lite/subscriber.rs | 127 +++-- rs/moq-net/src/model/group.rs | 78 +-- rs/moq-net/src/model/ledger.rs | 9 + rs/moq-net/src/model/resume.rs | 2 +- rs/moq-net/src/model/subscription.rs | 42 +- rs/moq-net/src/model/track.rs | 572 ++++++++------------- rs/moq-net/src/model/track/demand.rs | 9 +- rs/moq-net/src/model/track/fill.rs | 717 +++++++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 386 ++++++++++++++ 12 files changed, 1523 insertions(+), 503 deletions(-) create mode 100644 rs/moq-net/src/model/track/fill.rs create mode 100644 rs/moq-net/src/model/track/miss.rs diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 61f61d9cd60..2e8d64d1082 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -25,16 +25,12 @@ use crate::{ use super::{Message, Version, cluster, error::request, peer, request_update}; -/// Largest millisecond duration every implementation can carry losslessly. -const MAX_SAFE_DELAY_MS: u64 = super::max_age::MAX_MILLIS; - /// Preserve retained groups when the subscriber supplies no age budget. fn serving_subscription(subscriber_priority: u8) -> Subscription { Subscription { priority: super::priority::from_wire(subscriber_priority), - // Demand can cross a Lite hop before the producer's retention bound is - // known, so use the largest duration that remains wire-encodable. - max_delay: Duration::from_millis(MAX_SAFE_DELAY_MS), + // No Max Age is the publisher's retention alone, whatever it turns out to be. + max_delay: None, ..Default::default() } } @@ -1234,7 +1230,7 @@ where // a join, which the track's refresh ratio judges. let mut subscription = serving_subscription(msg.subscriber_priority).with_order(order); if let Some(max_age) = msg.max_age { - subscription.max_delay = Duration::from_millis(max_age); + subscription.max_delay = Some(Duration::from_millis(max_age)); } // A range that resolves without the live edge has a fixed start, which is no // join: bound the subscription before subscribing so the ratio never judges it. @@ -1736,7 +1732,7 @@ where } if let Some(max_age) = update.max_age { let mut subscription = serve.track.subscription(); - subscription.max_delay = Duration::from_millis(max_age); + subscription.max_delay = Some(Duration::from_millis(max_age)); serve.track.update(subscription).map_err(failed)?; } if let Some(value) = update.new_group { @@ -12822,7 +12818,7 @@ mod tests { } let subscription = serving_subscription(128); - assert_eq!(subscription.max_delay.as_millis(), MAX_SAFE_DELAY_MS as u128); + assert_eq!(subscription.max_delay, None, "no Max Age is the publisher's retention alone"); let mut subscriber = producer.subscribe(subscription); for sequence in [0, 1] { let group = subscriber diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index d590a411019..b7dfaeadaa3 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2756,8 +2756,7 @@ where super::max_age::millis( aggregate .as_ref() - .map(|s| s.max_delay) - .unwrap_or(Duration::from_millis(super::max_age::MAX_MILLIS)), + .map_or(Duration::MAX, |s| s.budget()), ) }); let order = aggregate.map(|s| s.order).unwrap_or_default(); @@ -3067,7 +3066,7 @@ where Idle, Revoked, Done(Result), - Fetch(group::Request), + Fill(track::Fill), Update(Update), } @@ -3119,8 +3118,8 @@ where fetch_done = true; } // An error is the track closing, which the arms below report. - if let Poll::Ready(Ok(request)) = dynamic.poll_requested_group(waiter) { - return Poll::Ready(End::Fetch(request)); + if let Poll::Ready(Ok(requested)) = dynamic.poll_requested_fill(waiter) { + return Poll::Ready(End::Fill(requested)); } let _ = group_fetches.poll(waiter); // A group past the answer's Largest also makes the copy current, so a join @@ -3139,7 +3138,7 @@ where if let Some(subscription) = subscription { priority = subscription.priority; if max_age_supported { - wanted_max_age = Some(super::max_age::millis(subscription.max_delay)); + wanted_max_age = Some(super::max_age::millis(subscription.budget())); } } } @@ -3210,13 +3209,10 @@ where revoked = true; break true; } - End::Fetch(request) => { - let fetch = self.clone().run_group_fetch( - broadcast_path.to_owned(), - track_name.to_owned(), - request, - timescale, - ); + End::Fill(requested) => { + let fetch = self + .clone() + .serve_fill(broadcast_path.to_owned(), track_name.to_owned(), requested, timescale); group_fetches.push(fetch); } End::Idle => { @@ -3326,7 +3322,7 @@ where let mut linger = crate::time::Deadline::new(&self.runtime); let mut subscribed = idle.track.subscription().is_some(); enum Step { - Fetch(group::Request), + Fill(track::Fill), Subscribe, Expired, Revoked, @@ -3340,8 +3336,8 @@ where if gate.poll_denied(waiter).is_ready() { return Poll::Ready(Step::Revoked); } - if let Poll::Ready(Ok(request)) = idle.dynamic.poll_requested_group(waiter) { - return Poll::Ready(Step::Fetch(request)); + if let Poll::Ready(Ok(fill)) = idle.dynamic.poll_requested_fill(waiter) { + return Poll::Ready(Step::Fill(fill)); } let _ = group_fetches.poll(waiter); while let Poll::Ready(Ok(subscription)) = idle.track.poll_subscription_changed(waiter) { @@ -3367,11 +3363,11 @@ where .await; match step { - Step::Fetch(request) => { - let fetch = self.clone().run_group_fetch( + Step::Fill(fill) => { + let fetch = self.clone().serve_fill( broadcast_path.to_owned(), track_name.to_owned(), - request, + fill, idle.timescale, ); group_fetches.push(fetch); @@ -4852,6 +4848,26 @@ where Ok(Fetched::Frame) } + /// Serve a fill a group at a time, newest first for a descending reader, until it is + /// answered or nobody waits on it. + async fn serve_fill(self, broadcast: PathOwned, name: String, fill: track::Fill, timescale: Option) { + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + { + // Every fetch answers its group, so the same one coming back is a bug; ending the + // fill names it dropped rather than fetching it forever. + if last == Some(sequence) { + debug_assert!(false, "fill group {sequence} left unanswered"); + return; + } + last = Some(sequence); + self.clone() + .run_group_fetch(broadcast.clone(), name.clone(), fill.group(sequence), timescale) + .await; + } + } + /// Fetch a group from the publisher to fill a cache miss, with a standalone (or /// draft-20 filtered) FETCH for this subscription's track. It asks from the frame the reader wants through the end /// of the group, so a publisher that evicted the prefix can still answer. @@ -4863,7 +4879,7 @@ where self, broadcast: PathOwned, name: String, - request: group::Request, + request: track::GroupFill, timescale: Option, ) { let validity = self.validity.start(); @@ -4955,7 +4971,7 @@ where if let Poll::Ready(res) = response { return Poll::Ready(Some(res)); } - request.demand().poll_unused(waiter).map(|_| None) + request.poll_unused(waiter).map(|_| None) }) .await } @@ -5030,9 +5046,9 @@ where let end = (end.group == sequence).then_some(end.object); // Joined fetches still count until they pick the accepted group up from the cache. - let joined = request.result.clone(); + let joined = request.joined(); // The track was accepted before any fetch was asked, so it keeps that info. - let track = request.state.clone(); + let track = request.track(); let mut producer = match request.accept(None) { Ok(producer) => producer, // Already served by a concurrent fetch, or the track closed. diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 63504585370..9d14651c18c 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -92,7 +92,7 @@ struct Shared { } /// Largest millisecond duration every implementation can carry losslessly. -const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; +pub(super) const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; /// The budget to serve a peer with, given what its wire could tell us. /// @@ -101,10 +101,10 @@ const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; /// as real time would discard backlog a legacy subscriber never declined, so fall /// back to a window wide enough not to drop and leave enforcement to the receiver, /// exactly as the IETF path does for the same reason. -fn serving_max_delay(version: Version, requested: Duration) -> Duration { +fn serving_max_delay(version: Version, requested: Duration) -> Option { match version.carries_max_delay() { - true => requested, - false => Duration::from_millis(MAX_SAFE_DELAY_MS), + true => Some(requested), + false => Some(Duration::from_millis(MAX_SAFE_DELAY_MS)), } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 2254f3c7014..c606cff201c 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -5800,7 +5800,7 @@ impl TrackServe { let start_moved = active.start != subscription.start(); active.priority = subscription.priority; active.order = subscription.order; - active.max_delay = subscription.max_delay; + active.max_delay = wire_max_delay(subscription.max_delay); // A lowered floor owes groups nobody asked for until now, in the track's // arrivals that held fetches read as well as in the session's tail. let lowered = subscription.start().map(|start| { @@ -5811,7 +5811,7 @@ impl TrackServe { producer.demand_arrivals(groups.clone()); } if let Ok(mut tail) = active.tail.write() { - tail.set_grace(tail::grace(subscription.max_delay)); + tail.set_grace(tail::grace(subscription.budget())); if let Some(groups) = lowered { tail.demand(groups, self.subscriber.runtime.now()); } @@ -5886,7 +5886,7 @@ impl TrackServe { tracing::info!(id, broadcast = %self.subscriber.log_path(&self.path), track = %self.name, "subscribe started"); - let tail = kio::Producer::new(Tail::new(tail::grace(subscription.max_delay))); + let tail = kio::Producer::new(Tail::new(tail::grace(subscription.budget()))); self.subscriber.subscribes.lock().insert( id, TrackEntry { @@ -5977,6 +5977,12 @@ enum Begin { Establish(Establish), } +/// The `Subscriber Max Age` a SUBSCRIBE carries for `max_delay`: `None` is the publisher's +/// max age alone, which the largest value every peer can read leaves to its clamp. +fn wire_max_delay(max_delay: Option) -> std::time::Duration { + max_delay.unwrap_or(std::time::Duration::from_millis(super::MAX_SAFE_DELAY_MS)) +} + /// Buffer the current SUBSCRIBE parameters with an updated end. The caller flushes. fn buffer_update( active: &mut SubStream, @@ -6043,7 +6049,7 @@ impl Establish { broadcast: self.serve.path.as_path(), track: self.serve.name.as_str().into(), priority: self.subscription.priority, - max_delay: self.subscription.max_delay, + max_delay: wire_max_delay(self.subscription.max_delay), start_group: bounds.start_group, end_group: bounds.end_group, start_frame: bounds.start_frame, @@ -6096,7 +6102,7 @@ impl Establish { SubStream { stream, id: self.id, - max_delay: self.subscription.max_delay, + max_delay: wire_max_delay(self.subscription.max_delay), start: self.subscription.start(), priority: self.subscription.priority, order: self.subscription.order, @@ -6455,7 +6461,7 @@ struct ServeLoop { /// Serve on-demand fetches of uncached groups from this session. dynamic: track::Dynamic, sub: Sub, - fetches: kio::Tasks>, + fetches: kio::Tasks>, /// Fetches a pre-lite-05 upstream has no FETCH for, held for the live feed. feed_fetches: FeedFetches, // A dedicated close-watch handle for the session-died arm. @@ -6508,7 +6514,7 @@ struct FeedFetches { } struct HeldFetch { - req: group::Request, + req: track::GroupFill, waiter: kio::Waiter, } @@ -6539,7 +6545,14 @@ impl FeedFetches { } } - fn push(&mut self, req: group::Request, feed: track::Consumer) { + /// Hold every group of `fill` for the live feed. + fn push_fill(&mut self, fill: track::Fill, feed: &track::Consumer) { + for sequence in fill.start().group..fill.end() { + self.push(fill.group(sequence), feed.clone()); + } + } + + fn push(&mut self, req: track::GroupFill, feed: track::Consumer) { let Some(arrivals) = feed.arrivals() else { req.reject(Error::NotFound); return; @@ -6553,7 +6566,7 @@ impl FeedFetches { // The last caller may leave and a replacement attempt arrive before the // queued cancellation gets polled. The old attempt owns no callers now. if let Some(previous) = self.held.remove(&sequence) { - debug_assert!(previous.req.demand().poll_unused(&kio::Waiter::noop()).is_ready()); + debug_assert!(previous.req.poll_unused(&kio::Waiter::noop()).is_ready()); } if self.held.len() >= self.max { req.reject(Error::TooManyRequests); @@ -6625,7 +6638,7 @@ impl FeedFetches { { self.polled += 1; } - if held.req.demand().poll_unused(&held.waiter).is_ready() { + if held.req.poll_unused(&held.waiter).is_ready() { settled.push((held.req, None)); continue; } @@ -6756,16 +6769,17 @@ impl ServeLoop { // (1) Track demand: a fetch, a subscription change, or the origin // handing the track to another route. - // A fetch is cheap and one-shot, so serve it ahead of subscription churn. - match self.dynamic.poll_requested_group(waiter) { - Poll::Ready(Ok(req)) => { + // A fill is bounded and serves readers already waiting, so serve it ahead of + // subscription churn. + match self.dynamic.poll_requested_fill(waiter) { + Poll::Ready(Ok(fill)) => { if self.supports_fetch { self.fetches - .push(FetchServeRun::new(serve.clone(), req, self.timescale)); + .push(FillServeRun::new(serve.clone(), fill, self.timescale)); } else { // No FETCH on the wire: wait for the live feed, which a // subscription arriving alongside may still be opening. - self.feed_fetches.push(req, self.serving.consume()); + self.feed_fetches.push_fill(fill, &self.serving.consume()); } continue; } @@ -6909,10 +6923,14 @@ impl ServeLoop { // The groups the SUBSCRIBE asked for below it are not // waited for, whatever the demand asks later. One that // still arrives is delivered. - if let Some(requested) = active.requested - && let Ok(mut tail) = active.tail.write() - { - tail.account(requested.group..start.group, serve.subscriber.runtime.now()); + // Recorded as dropped too, so a reader asking for them + // later is told so rather than fetching what the publisher + // already gave up. + if let Some(requested) = active.requested { + if let Ok(mut tail) = active.tail.write() { + tail.account(requested.group..start.group, serve.subscriber.runtime.now()); + } + self.serving.drop_groups(requested.group..start.group); } } // The publisher will never send these groups, so they @@ -6959,7 +6977,7 @@ impl ServeLoop { // subscriber was willing to wait for a late group anyway. if let Ok(mut tail) = active.tail.write() { tail.set_grace(tail::grace( - subscription.map(|sub| sub.max_delay).unwrap_or_default(), + subscription.map(|sub| sub.budget()).unwrap_or_default(), )); tail.expire(serve.subscriber.runtime.now()); } @@ -6989,6 +7007,59 @@ impl ServeLoop { } } +/// Serves one fill a group at a time, newest first for a descending reader, each with its +/// own [`FetchServeRun`], until it is answered or nobody waits on it. +struct FillServeRun { + serve: TrackServe, + fill: track::Fill, + timescale: Option, + run: Option<(u64, FetchServeRun)>, +} + +impl FillServeRun { + fn new(serve: TrackServe, fill: track::Fill, timescale: Option) -> Self { + Self { + serve, + fill, + timescale, + run: None, + } + } +} + +impl kio::Task for FillServeRun { + type Output = (); + + fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + loop { + let last = match &mut self.run { + Some((sequence, run)) => { + ready!(run.poll(waiter)); + let sequence = *sequence; + self.run = None; + Some(sequence) + } + None => None, + }; + // Withdrawn: nobody waits on what is left, so it records nothing. + if self.fill.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + let Some(sequence) = self.fill.next() else { + return Poll::Ready(()); + }; + // Every run answers its group, so the same one coming back is a bug; ending the + // fill names it dropped rather than fetching it forever. + if last == Some(sequence) { + debug_assert!(false, "fill group {sequence} left unanswered"); + return Poll::Ready(()); + } + let run = FetchServeRun::new(self.serve.clone(), self.fill.group(sequence), self.timescale); + self.run = Some((sequence, run)); + } + } +} + /// Serves one downstream fetch end-to-end on its own bidi stream: send FETCH, /// then fill the group from the bare FRAME messages that follow. The timescale /// comes from this track's TRACK_INFO (already known), and the group sequence @@ -7005,16 +7076,16 @@ struct FetchServeRun { enum FetchRunState { Open { - request: Option, + request: Option, }, Send { - request: Option, + request: Option, stream: Stream, frame_start: u64, }, /// Flushed; waiting for the publisher to answer before accepting. Answer { - request: Option, + request: Option, stream: Stream, frame_start: u64, }, @@ -7023,7 +7094,7 @@ enum FetchRunState { producer: crate::recv::Group, // The accepted request's demand: fetches that joined it but have not yet // picked the group up from the cache. - joined: kio::Producer, + joined: kio::Producer, // Boxed so the other states stay small; one allocation per fetch. ingest: Box, }, @@ -7032,7 +7103,7 @@ enum FetchRunState { impl FetchRunState { /// The downstream request, while it waits on the publisher's answer. - fn request(&self) -> Option<&group::Request> { + fn request(&self) -> Option<&track::GroupFill> { match self { Self::Open { request } | Self::Send { request, .. } | Self::Answer { request, .. } => request.as_ref(), Self::Ingest { .. } | Self::Done => None, @@ -7041,7 +7112,7 @@ impl FetchRunState { } impl FetchServeRun { - fn new(serve: TrackServe, request: group::Request, timescale: Option) -> Self { + fn new(serve: TrackServe, request: track::GroupFill, timescale: Option) -> Self { let session = serve.subscriber.session.clone(); let group = request.sequence(); Self { @@ -7120,7 +7191,7 @@ impl kio::Task for FetchServeRun { // A fetch nobody waits on any more is cancelled upstream, so the publisher stops // serving it (and a relay there releases its own FETCH). Ingest has its own check. if let Some(request) = self.state.request() - && request.demand().poll_unused(waiter).is_ready() + && request.poll_unused(waiter).is_ready() { tracing::debug!(track = %self.serve.name, group = self.group, "fetch abandoned"); if let FetchRunState::Send { stream, .. } | FetchRunState::Answer { stream, .. } = @@ -7252,7 +7323,7 @@ impl kio::Task for FetchServeRun { let group_info = track::Info::default().with_timescale(self.timescale); // The joined fetches pick the group up from the cache only when next // polled, so their demand outlives the request until then. - let joined = request.result.clone(); + let joined = request.joined(); let mut producer = match request.accept(group_info) { Ok(producer) => producer, Err(err) => { diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index a6737f39043..7a78aca5ccb 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -1709,7 +1709,7 @@ impl Handle { pub(crate) fn demand(&self) -> Demand { Demand { sequence: self.info.sequence, - state: DemandSource::Group(self.state.weak()), + state: self.state.weak(), } } @@ -1726,41 +1726,25 @@ impl Handle { } } -/// A cloneable, watch-only handle to a group's readers or pending fetch callers. +/// A cloneable, watch-only handle to a group's readers. /// -/// An accepted or dropped request ends waits with [`Error::Dropped`]; a rejected request keeps -/// its rejection reason. +/// A finished or dropped group ends waits with [`Error::Dropped`]; an aborted one keeps +/// its abort reason. #[derive(Clone)] pub struct Demand { sequence: u64, - state: DemandSource, -} - -#[derive(Clone)] -enum DemandSource { - Group(kio::ProducerWeak), - Fetch(kio::ProducerWeak), + state: kio::ProducerWeak, } impl Demand { - pub(super) fn fetch(sequence: u64, state: kio::ProducerWeak) -> Self { - Self { - sequence, - state: DemandSource::Fetch(state), - } - } - /// The group sequence this handle watches. pub fn sequence(&self) -> u64 { self.sequence } - /// Whether the group or fetch is open and has readers right now. + /// Whether the group is open and has readers right now. pub fn is_used(&self) -> bool { - match &self.state { - DemandSource::Group(state) => !state.is_closed() && state.is_used(), - DemandSource::Fetch(state) => !state.is_closed() && state.is_used(), - } + !self.state.is_closed() && self.state.is_used() } /// Wait until at least one reader needs the group. @@ -1777,38 +1761,23 @@ impl Demand { /// Poll until at least one reader needs the group. pub fn poll_used(&self, waiter: &kio::Waiter) -> Poll> { - match &self.state { - DemandSource::Group(state) => state.poll_used(waiter), - DemandSource::Fetch(state) => state.poll_used(waiter), - } - .map_err(|_| self.abort_reason()) + self.state.poll_used(waiter).map_err(|_| self.abort_reason()) } /// Poll until no reader needs the group. pub fn poll_unused(&self, waiter: &kio::Waiter) -> Poll> { - match &self.state { - DemandSource::Group(state) => state.poll_unused(waiter), - DemandSource::Fetch(state) => state.poll_unused(waiter), - } - .map_err(|_| self.abort_reason()) + self.state.poll_unused(waiter).map_err(|_| self.abort_reason()) } - /// Wait until the group or fetch closes, returning its cause. + /// Wait until the group closes, returning its cause. #[cfg(feature = "async")] pub async fn closed(&self) -> Error { - match &self.state { - DemandSource::Group(state) => state.closed().await, - DemandSource::Fetch(state) => state.closed().await, - } + self.state.closed().await; self.abort_reason() } fn abort_reason(&self) -> Error { - match &self.state { - DemandSource::Group(state) => state.read().abort.clone(), - DemandSource::Fetch(state) => state.read().rejected.clone(), - } - .unwrap_or(Error::Dropped) + self.state.read().abort.clone().unwrap_or(Error::Dropped) } } @@ -2673,29 +2642,6 @@ impl Fetch { } } -/// A consumer's request for a single past group, handed to a handler via -/// [`track::Dynamic::poll_requested_group`]. -/// -/// The handler fulfills it by calling [`Self::accept`], which inserts the group -/// into the track cache (resolving every [`track::Consumer::fetch_group`] that joined the -/// attempt) and returns a [`Producer`] to fill. A relay typically opens a wire -/// FETCH and waits for the publisher to answer before accepting, so a group the -/// publisher lacks is rejected rather than accepted and then aborted. The request -/// carries its own producer handle, so it works the same whether or not the track -/// has been accepted yet. -pub struct Request { - // pending: /quest/m1/public-model/deferred.md - pub(crate) state: kio::Producer, - pub(super) fetch: kio::Shared, - pub(super) sequence: u64, - pub(super) priority: u8, - pub(super) max_delay: Option, - pub(super) frame_start: u64, - // pending: /quest/m1/public-model/deferred.md - pub(crate) result: kio::Producer, - pub(super) done: bool, -} - #[cfg(test)] mod test { use super::*; diff --git a/rs/moq-net/src/model/ledger.rs b/rs/moq-net/src/model/ledger.rs index 64dad02890e..051f4670875 100644 --- a/rs/moq-net/src/model/ledger.rs +++ b/rs/moq-net/src/model/ledger.rs @@ -122,6 +122,15 @@ impl Ledger { (run.groups.start <= sequence).then_some(run.groups.end) } + /// The accounted runs overlapping `groups`, clipped to it, lowest first. + pub fn accounted(&self, groups: Range) -> impl Iterator> + '_ { + let first = self.runs.partition_point(|run| run.groups.end <= groups.start); + self.runs[first..] + .iter() + .take_while(move |run| run.groups.start < groups.end) + .map(move |run| run.groups.start.max(groups.start)..run.groups.end.min(groups.end)) + } + /// Whether every group in `groups` is accounted for. pub fn covers(&self, groups: Range) -> bool { // Runs merge on insert, so one run covers the span or none does. diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index a5c9232e0c4..88946f7a03f 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1425,7 +1425,7 @@ impl Recover { // A held group or frame may be the only thing being polled. Mirror and watch // preferences here too, before judging its budget against the live edge. reader.sync(waiter); - Some(reader.mirrored.max_delay) + Some(reader.mirrored.budget()) } /// Commit the replacement only once the caller has acquired its cursor or frame. diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 98177f43798..05f920a0a89 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -19,18 +19,22 @@ pub(super) use window::{Containing, Held, Holdings, Resolved, Unheld, Window, la pub struct Subscription { /// Delivery priority. Higher values preempt lower ones when bandwidth is constrained. pub priority: u8, - /// How long a superseded group stays worth delivering to this subscriber. + /// How long a superseded group stays worth delivering to this subscriber, or `None` + /// for as long as the publisher keeps it. /// - /// [`Duration::ZERO`] (the default) skips immediately: group 8 arriving means group 7 - /// is abandoned, timed or not. A larger budget tolerates that much reordering before giving up. - /// This never *adds* delay, since the bound is only reached once newer data is - /// already that far ahead. + /// `Some(Duration::ZERO)` (the default) skips immediately: group 8 arriving means group + /// 7 is abandoned, timed or not. A larger budget tolerates that much reordering before + /// giving up. This never *adds* delay, since the bound is only reached once newer data + /// is already that far ahead. `None` is the publisher's + /// [`Info::max_age`](crate::track::Info::max_age) alone, what a reader reaching back + /// for history wants. /// /// This is the `Subscriber Max Age` on the wire, and it is stored here verbatim so /// what was asked for stays readable. Encoded as milliseconds in a QUIC varint, so - /// a duration of `2^62` milliseconds or more cannot be put on the wire. Clamped to - /// the publisher's [`Info::max_age`](crate::track::Info::max_age), since waiting for - /// a group longer than it is kept around cannot produce it. + /// a duration of `2^62` milliseconds or more cannot be put on the wire; `None` goes out + /// as the largest value every peer can read. Clamped to the publisher's + /// [`Info::max_age`](crate::track::Info::max_age), since waiting for a group longer + /// than it is kept around cannot produce it. /// /// It caps every one of [`Self::ranges`]: a range names what may be delivered, and this /// budget skips what inside it has gone stale. @@ -67,7 +71,7 @@ pub struct Subscription { /// when something collects the track's cache pool: an origin's driver does, and a /// standalone track needs [`Pool::gc`](crate::cache::Pool::gc) called, as its idle /// expiry already does. Otherwise it is judged only when something else wakes the read. - pub max_delay: Duration, + pub max_delay: Option, /// The spans of the track to deliver; see [`Range`]. /// /// The default is [`Range::oldest`]: everything [`Self::max_delay`] still considers @@ -262,7 +266,7 @@ impl Default for Subscription { fn default() -> Self { Self { priority: 0, - max_delay: Duration::ZERO, + max_delay: Some(Duration::ZERO), ranges: vec![Range::oldest()], order: Order::Descending, } @@ -276,13 +280,18 @@ impl Subscription { self } - /// Set how far a group may fall behind the live edge before it is skipped, returning - /// `self` for chaining. - pub fn with_max_delay(mut self, max_delay: Duration) -> Self { - self.max_delay = max_delay; + /// Set how far a group may fall behind the live edge before it is skipped, `None` for + /// the publisher's max age alone, returning `self` for chaining. + pub fn with_max_delay(mut self, max_delay: impl Into>) -> Self { + self.max_delay = max_delay.into(); self } + /// [`Self::max_delay`] as a budget: `None` waits as long as anything could. + pub(crate) fn budget(&self) -> Duration { + self.max_delay.unwrap_or(Duration::MAX) + } + /// Replace [`Self::ranges`], returning `self` for chaining. pub fn with_ranges(mut self, ranges: impl IntoIterator) -> Self { self.ranges = ranges.into_iter().collect(); @@ -445,7 +454,10 @@ impl Combined { return; }; combined.priority = combined.priority.max(sub.priority); - combined.max_delay = combined.max_delay.max(sub.max_delay); + combined.max_delay = match (combined.max_delay, sub.max_delay) { + (Some(a), Some(b)) => Some(a.max(b)), + _ => None, + }; combined.ranges.extend_from_slice(&sub.ranges); // Sequence-first prioritization is enabled only when every subscriber wants it. if let Order::Descending = sub.order { diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index db1942a7c40..f5dcd68c765 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -18,7 +18,7 @@ use crate::{broadcast, cache, group, stats}; use super::delivered::Delivered; use super::ledger::{self, Ledger}; -use super::{Datagram, Requests}; +use super::Datagram; use super::Cap; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; @@ -29,6 +29,13 @@ mod demand; pub(super) use demand::Preferences; use demand::Subscriptions; +mod fill; +mod miss; +pub use fill::Fill; +// private: capability gap +pub(crate) use fill::{GroupFill, Outcome as FillOutcome}; +use fill::FillState; + use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, ops::{self, Bound, RangeBounds}, @@ -451,9 +458,9 @@ pub(crate) struct TrackState { // Kept here (rather than threaded through every handle) so any holder reaches it. subscriptions: kio::Lock, - // The reverse fetch queue (see [`FetchState`]), same reasoning: cache-miss - // `fetch_group` calls enqueue here and a `Dynamic` drains. - fetch: kio::Shared, + // The fills readers queued for groups they miss (see [`Fill`]), same reasoning: a miss + // queues here and a `Dynamic` drains. + fills: kio::Shared, // A front's logical track: read straight from its routes' copies rather than this // cache, which stays empty; see [`super::resume`]. @@ -633,45 +640,6 @@ impl Slot { pub(super) const CACHE_OVERHEAD: u64 = 2 * (size_of::() + size_of::() + size_of::<(u64, Timestamp)>() + 2 * size_of::<(u64, u32)>()) as u64; -/// Reverse state for [`Consumer::fetch_group`], beside the track state in its own -/// [`kio::Shared`]: consumers enqueue (coalescing per sequence, so a relay opens one -/// upstream FETCH per group) and [`Dynamic`] handlers drain under one lock, without -/// write access to the track itself. -pub(super) type FetchState = Requests; - -/// One fetch attempt for a sequence, shared by every [`Fetching`] that joined it. -pub(super) struct PendingFetch { - // The most demanding delivery priority across the joined fetches. - priority: u8, - - // The most tolerant reader budget. None is unbounded and therefore dominates. - max_delay: Option, - - // The lowest start across the joined fetches, so serving the attempt once satisfies - // every one of them. Only widened while the request is still queued: once a handler - // has taken it, its range is already on the wire. - frame_start: u64, - - // Result channel for joined fetches. An accept resolves covered callers through - // the track cache and refuses uncovered ones. Dropping every producer closes - // an unserved attempt, which a [`Fetching`] reads as [`Error::NotFound`]. - result: kio::Producer, -} - -/// Whether a fetch attempt was accepted, or why it was rejected. -#[derive(Default)] -// pending: /quest/m1/public-model/deferred.md -pub(crate) struct FetchOutcome { - // private: test - pub(crate) rejected: Option, - // The budget the rejected attempt asked its handler for, so a looser joiner can tell - // that an `Old` refusal was not its own. - asked: Option, - // An accept still refuses joined fetches the cached group cannot cover, without - // giving the request's demand watcher a rejection reason. - accepted: bool, -} - /// Where the groups this hop holds start, for resolving time anchors: ordered lookups in /// the starts index and the cache, so no query walks more than the run it answers. impl Holdings for TrackState { @@ -1330,8 +1298,8 @@ impl TrackState { /// Resolve a one-shot fetch from the track side: the cached group, or an [`Error`] /// once it can never be served. A missing group is a failure ([`Error::NotFound`]), not an - /// end-of-stream. The handler side (a rejection, or no [`Dynamic`] at all) lives - /// in [`FetchState`]; [`Fetching`] polls both. + /// end-of-stream. The handler side (a refusal, or no [`Dynamic`] at all) lives + /// in the track's fills; [`Fetching`] polls both. fn poll_fetch_cached(&self, sequence: u64, frame_start: u64) -> Poll> { // A group aborted between the lookup and the consume (an abandoned fetch cut // short) is a miss, not a hit that fails on its first read. @@ -1605,7 +1573,7 @@ impl TrackState { return; } let max_delay = snapshot_subscription(&self.subscriptions, self.max_age_bound()) - .map_or(Duration::ZERO, |subscription| subscription.max_delay); + .map_or(Duration::ZERO, |subscription| subscription.budget()); self.arrivals.set_grace(ledger::grace(max_delay)); self.arrivals.expire(now); } @@ -2247,7 +2215,7 @@ impl TrackState { Ok(()) } - /// Insert a group fetched for a [`group::Request`], setting the track's [`Info`] + /// Insert a group a [`Fill`] answered, setting the track's [`Info`] /// if it isn't accepted yet. The group's timescale comes from that info, so a /// fetch can serve an as-yet-unaccepted track (e.g. a relay with no live /// subscription). The group lands in the cache so a waiting @@ -3146,84 +3114,34 @@ impl Producer { } } -/// Pop the next queued group fetch off the fetch queue and wrap it in a -/// [`group::Request`] bound to a fresh producer handle. Shared by every -/// [`Dynamic`] handle on the track. -fn poll_requested_group( - state: &kio::Producer, - fetch: &kio::Shared, - waiter: &kio::Waiter, -) -> Poll> { - // Prefer serving a queued fetch, even if the track has since aborted. - if let Poll::Ready(mut guard) = fetch.poll(waiter, |fetch| { - if fetch.has_queued() { - Poll::Ready(()) - } else { - Poll::Pending - } - }) { - let sequence = guard.pop().expect("predicate guaranteed a request"); - // The popped attempt stays pending, so a fetch in the window between hand-off - // and accept joins it instead of queueing a duplicate. - // `group::Request::{accept, reject, drop}` removes the entry, as does the last - // `Fetching` leaving. - let pending = guard.get(&sequence).expect("popped key must be pending"); - let priority = pending.priority; - let max_delay = pending.max_delay; - let frame_start = pending.frame_start; - let result = pending.result.clone(); - drop(guard); - return Poll::Ready(Ok(group::Request { - state: state.clone(), - fetch: fetch.clone(), - sequence, - priority, - max_delay, - frame_start, - result, - done: false, - })); - } - - // No fetch queued: surface a track abort so the handler loop can exit. - match state.poll_ref(waiter, |state| match &state.abort { - Some(err) => Poll::Ready(err.clone()), - None => Poll::Pending, - }) { - Poll::Ready(Ok(err)) => Poll::Ready(Err(err)), - Poll::Ready(Err(closed)) => Poll::Ready(Err(closed.abort.clone().unwrap_or(Error::Dropped))), - Poll::Pending => Poll::Pending, - } -} - -/// Serves on-demand fetches of uncached (old) groups for a track, the group-level -/// analogue of [`broadcast::Dynamic`]. +/// Serves the groups a track's readers miss, the group-level analogue of +/// [`broadcast::Dynamic`]. /// /// Most tracks never serve old content, so this capability lives on a dedicated /// handle rather than [`Producer`]: a relay creates one (via /// [`Producer::dynamic`] or [`Request::dynamic`]) to pull past groups -/// from upstream. While at least one is alive the track will block a cache-miss -/// [`Consumer::fetch_group`] waiting to be served; with none, an accepted track's -/// miss fails fast with [`Error::NotFound`]. +/// from upstream. While at least one is alive a reader's miss queues a [`Fill`] for it +/// to serve; with none, the miss is named dropped, and a cache-miss +/// [`Consumer::fetch_group`] on an accepted track fails fast with [`Error::NotFound`]. pub struct Dynamic { name: Arc, // Kept to insert served groups into the cache and observe track abort. state: kio::Producer, - // The fetch queue this handle drains; its `dynamic` count gates `fetch_group`. - fetch: kio::Shared, - // Shared with the track's producers: a handler still serving fetches keeps the + // The fills this handle drains; its handler count gates queueing. + fills: kio::Shared, + // Shared with the track's producers: a handler still serving fills keeps the // track alive, like a producer clone does. alive: Arc, } impl Dynamic { fn new(name: Arc, state: kio::Producer, alive: Arc) -> Self { - let fetch = state.read().fetch.clone(); - fetch.lock().add_handler(); + let fills = state.read().fills.clone(); + fills.lock().add_handler(); Self { name, state, - fetch, + fills, alive, } } @@ -3233,19 +3151,19 @@ impl Dynamic { &self.name } - /// Block until a consumer fetches a group that isn't cached, returning a - /// [`group::Request`] to serve via [`group::Request::accept`]. + /// Block until readers miss a run of groups, returning a [`Fill`] to serve via + /// [`Fill::accept`] and [`Fill::drop_groups`]. /// - /// A relay issues a wire FETCH first; an origin already has the group cached, so - /// the fetch resolves without ever reaching here. Errors once the track is aborted. + /// A relay fetches the run upstream; an origin already has its groups cached, so + /// nothing reaches here. Errors once the track is aborted. #[cfg(feature = "async")] - pub async fn requested_group(&self) -> Result { - kio::wait(|waiter| self.poll_requested_group(waiter)).await + pub async fn requested_fill(&self) -> Result { + kio::wait(|waiter| self.poll_requested_fill(waiter)).await } - /// Poll for a group request to serve. - pub fn poll_requested_group(&self, waiter: &kio::Waiter) -> Poll> { - poll_requested_group(&self.state, &self.fetch, waiter) + /// Poll for a fill to serve. + pub fn poll_requested_fill(&self, waiter: &kio::Waiter) -> Poll> { + fill::poll_requested(&self.state, &self.fills, waiter) } /// Watch subscriber demand without keeping the track alive. @@ -3257,11 +3175,11 @@ impl Dynamic { impl Clone for Dynamic { fn clone(&self) -> Self { // Count each live handle (mirrors `broadcast::Dynamic`). - self.fetch.lock().add_handler(); + self.fills.lock().add_handler(); Self { name: self.name.clone(), state: self.state.clone(), - fetch: self.fetch.clone(), + fills: self.fills.clone(), alive: self.alive.clone(), } } @@ -3270,14 +3188,10 @@ impl Clone for Dynamic { impl Drop for Dynamic { fn drop(&mut self) { // Unlike `broadcast::Dynamic`, dropping the last handle doesn't abort the track: - // a live `Producer` may still be serving the subscription. It just stops fetch - // serving. Queued attempts no handler will ever pop are dropped, closing their - // result channels so every joined `Fetching` resolves NotFound; an attempt - // already handed to a handler stays, resolved by its `group::Request` instead. - let mut fetch = self.fetch.lock(); - if fetch.remove_handler() { - fetch.drain_queued(); - } + // a live `Producer` may still be serving the subscription. It just stops serving + // fills: a queued one no handler will ever take closes, so its readers stop + // waiting on it, while one already taken stays with its `Fill`. + self.fills.lock().remove_handler(); } } @@ -3443,7 +3357,9 @@ fn clamp_max_delay(mut max_delay: Duration, bound: Option) -> Duration /// Clamp the aggregate's max delay budget to the publisher's window; see [`clamp_max_delay`]. fn clamp_combined(combined: Option, bound: Option) -> Option { let mut combined = combined?; - combined.max_delay = clamp_max_delay(combined.max_delay, bound); + if bound.is_some() { + combined.max_delay = Some(clamp_max_delay(combined.budget(), bound)); + } Some(combined) } @@ -4184,8 +4100,8 @@ impl Consumer { /// (past the final sequence, or no [`Dynamic`] on the track), [`Error::NotFetchable`] /// instead when this track sent the sequence as a datagram still buffered, the handler's /// rejection (a relay's upstream miss is [`StreamError::NotFound`](crate::StreamError::NotFound)), - /// or the track's abort error if it's already closed. Concurrent fetches for the same sequence coalesce onto one - /// handler request. + /// or the track's abort error if it's already closed. A miss queues a one-group [`Fill`], + /// which concurrent fetches of the same sequence join. pub fn fetch_group(&self, sequence: u64, options: impl Into>) -> kio::Pending { let options = options.into().unwrap_or_default(); let state = self.resolve(None).0; @@ -4202,7 +4118,7 @@ impl Consumer { if let Some(resume) = resume { return kio::Pending::new(Fetching { state: state.clone(), - fetch: state.read().fetch.clone(), + fills: state.read().fills.clone(), sequence, frame_start: options.frame_start, priority: options.priority, @@ -4217,10 +4133,10 @@ impl Consumer { // Queue a request only when the group isn't already resolvable from the track // (cached, aborted, or past-final all resolve through `Fetching::poll` without // a queue entry). - let (fetch, cached) = { + let (fills, cached) = { let state = state.read(); ( - state.fetch.clone(), + state.fills.clone(), state.poll_fetch_cached(sequence, options.frame_start), ) }; @@ -4233,12 +4149,12 @@ impl Consumer { }; if unresolved { - result = queue(&fetch, sequence, &options); + result = queue(&fills, sequence, &options); } kio::Pending::new(Fetching { state, - fetch, + fills, sequence, frame_start: options.frame_start, priority: options.priority, @@ -4480,159 +4396,25 @@ impl kio::Task for Querying { } } -impl group::Request { - /// Watch the callers waiting for this fetch without keeping the attempt alive. - /// - /// The last caller to leave withdraws the attempt, so a later fetch of the group - /// queues a fresh request rather than joining this one: once unused, demand never - /// returns, so drop the request. - pub fn demand(&self) -> group::Demand { - group::Demand::fetch(self.sequence, self.result.weak()) - } - - /// The group sequence the consumer wants. - pub fn sequence(&self) -> u64 { - self.sequence - } - - /// The delivery priority the consumer requested for this group. - pub fn priority(&self) -> u8 { - self.priority - } - - /// The widest age budget among callers sharing this upstream fetch, for a handler that forwards it. - /// Each caller still enforces its own, so a handler serving locally can ignore it. - pub fn max_delay(&self) -> Option { - self.max_delay - } - - /// The first frame of the group the consumer wants; 0 is the whole group. - /// - /// The group [`accept`](Self::accept) returns already starts here, so the frames a - /// handler writes carry the indices they have in the group rather than restarting at - /// 0. A handler serving from elsewhere moves it with - /// [`start_at`](group::Producer::start_at) before the first frame. - /// - /// There is no end: the handler fetches through the end of the group so the result - /// is cacheable for anyone (see [`group::Fetch::frame_start`]). - pub fn frame_start(&self) -> u64 { - self.frame_start - } - - /// Insert the fetched group into the track cache, resolving the waiting - /// [`Consumer::fetch_group`], and return a [`group::Producer`] to fill. - /// - /// The group's timescale comes from the track's [`Info`]. `info` sets that - /// info if the track hasn't been accepted yet (a fetch with no live subscription), - /// and is ignored once accepted. Returns [`Error::Duplicate`] if the group is - /// already present, or the track's abort error if it closed while pending. - /// - /// Accepting after every caller left still caches the group, which a fetch that - /// queued a fresh request meanwhile resolves from; that request's own accept is then - /// [`Error::Duplicate`]. - pub fn accept(mut self, info: impl Into>) -> Result { - self.done = true; - // Cache the group before removing the attempt: the joined fetches resolve - // through the cache, and removal closes their result channel (which alone - // would read as NotFound). - let res = TrackState::modify(&self.state) - .and_then(|mut state| state.insert_group_request(self.sequence, self.frame_start, info.into())); - self.remove(); - // A joined fetch the cached group can't cover (a wider start that joined after - // the range went on the wire) fails rather than waits. Closing the channel says - // the same, but a handler tracking the joined demand keeps it open. - if let Ok(mut outcome) = self.result.write() { - outcome.accepted = true; - } - res - } - - /// Declare the track's exclusive final sequence, as the publisher answering this - /// fetch reported it. A no-op once the track declared an end, or holds a later group. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - // pending: /quest/m1/public-model/deferred.md - pub(crate) fn finish_track_at(&self, final_sequence: u64) { - if let Ok(mut state) = TrackState::modify(&self.state) - && state.end.is_none() - { - let _ = state.set_end(Position::group(final_sequence)); - } - } - - /// Reject the fetch, resolving every joined [`Consumer::fetch_group`] with `err`. - pub fn reject(mut self, err: Error) { - self.done = true; - // Remove before writing, so a fetch arriving now starts a fresh attempt - // instead of joining a rejected one. - self.remove(); - if let Ok(mut outcome) = self.result.write() { - outcome.rejected = Some(err); - outcome.asked = self.max_delay; - } - } - - /// Remove this attempt from the fetch state, unless a newer attempt for the same - /// sequence has already replaced it. - fn remove(&self) { - withdraw(&mut self.fetch.lock(), self.sequence, |pending| { - pending.result.same_channel(&self.result) - }); - } -} - -/// Join the attempt for `sequence`, or queue a new one; `None` when no handler exists. -fn queue(fetch: &kio::Shared, sequence: u64, options: &group::Fetch) -> Option> { - let mut fetch = fetch.lock(); - if let Some(pending) = fetch.join(&sequence) { - // Join the in-flight attempt for this sequence (queued or already being - // served): share its result channel, raising its priority if ours is higher, - // widening its range if ours starts earlier, and its budget if ours is looser. - // - // Widening only reaches the handler while the attempt is still queued; - // once popped, the `group::Request` holds an immutable copy that is already on - // the wire. What protects the late caller either way: the coverage check - // refuses a group starting above what it asked for, so it fails cleanly and its - // retry queues a fresh attempt, and `Fetching::poll` re-queues a looser budget - // that a tighter one got refused as too old. - pending.priority = pending.priority.max(options.priority); - pending.max_delay = match (pending.max_delay, options.max_delay) { - (Some(current), Some(joined)) => Some(current.max(joined)), - (None, _) | (_, None) => None, - }; - pending.frame_start = pending.frame_start.min(options.frame_start); - return Some(Joined::new(&pending.result)); - } - // Queue a new attempt. The handler gate is atomic with a handler dropping (no fetch - // stranded on a queue nobody drains); with no handler, `Fetching::poll` fails fast. - let producer = kio::Producer::::default(); - let joined = Joined::new(&producer); - let attempt = PendingFetch { +/// Join the fill covering `sequence`, or queue a one-group fill; `None` when no handler +/// exists to take one. +fn queue(fills: &kio::Shared, sequence: u64, options: &group::Fetch) -> Option> { + let end = sequence.checked_add(1)?; + let ask = fill::Ask { + start: Position { + group: sequence, + frame: options.frame_start, + }, + end, priority: options.priority, max_delay: options.max_delay, - frame_start: options.frame_start, - result: producer, + // One group either way; newest first is the default order. + order: Order::default(), }; - fetch.insert(sequence, attempt).ok().map(|()| joined) -} - -/// Remove the attempt for `sequence` if it is still `ours`. Reads first: the attempt is -/// often already gone, and a write would wake every handler parked on the queue. -fn withdraw(fetch: &mut kio::Mut<'_, FetchState>, sequence: u64, ours: impl Fn(&PendingFetch) -> bool + Copy) { - if fetch.get(&sequence).is_some_and(ours) { - fetch.remove_if(&sequence, ours); - } -} - -impl Drop for group::Request { - fn drop(&mut self) { - if self.done { - return; - } - self.remove(); - if let Ok(mut outcome) = self.result.write() { - outcome.rejected = Some(Error::Dropped); - } - } + // The handler gate is atomic with a handler dropping (no fill stranded on a queue + // nobody drains); with no handler, `Fetching::poll` fails fast. + let (joins, _) = fills.lock().join(fills, &ask); + joins.into_iter().next().map(Box::new) } /// The pollable state of a [`Consumer::fetch_group`]. @@ -4642,7 +4424,7 @@ impl Drop for group::Request { /// or produced after a wire FETCH), or [`Error::NotFound`] if it can never exist. pub struct Fetching { state: kio::Consumer, - fetch: kio::Shared, + fills: kio::Shared, sequence: u64, // This caller's own start, so a cached group that begins above it is a miss // rather than a short answer. @@ -4651,9 +4433,9 @@ pub struct Fetching { priority: u8, // This caller's own age cap. None means only the publisher's max age. max_delay: Option, - // The attempt this fetch joined; `None` on a cache hit, or when no handler - // existed to queue on. Boxed: a miss already allocates, and the handle stays small. - result: Option>, + // The fill this fetch joined; `None` on a cache hit, or when no handler existed to + // queue on. Boxed: a miss already allocates, and the handle stays small. + result: Option>, // The group already cached when the fetch was made, held so it stays wanted. // Boxed: a group consumer dwarfs the rest of the handle. hit: Option>, @@ -4664,42 +4446,6 @@ pub struct Fetching { resume: Option>, } -/// A [`Fetching`]'s place in a queued or in-flight attempt. -struct Joined { - // Counts this caller in the attempt's demand and carries its outcome. - outcome: kio::Consumer, - // Names the attempt in the fetch state, so the last caller out withdraws only its own. - attempt: kio::ProducerWeak, -} - -impl Joined { - fn new(result: &kio::Producer) -> Box { - Box::new(Self { - outcome: result.consume(), - attempt: result.weak(), - }) - } -} - -impl Drop for Fetching { - fn drop(&mut self) { - let Some(joined) = self.result.take() else { - return; - }; - let Joined { outcome, attempt } = *joined; - // Joins take the fetch lock, so leaving under it is atomic with one: the joiner - // either keeps the attempt wanted, or finds it withdrawn and queues a fresh one - // rather than joining one its handler is about to drop. - let mut fetch = self.fetch.lock(); - drop(outcome); - if !attempt.is_used() { - withdraw(&mut fetch, self.sequence, |pending| { - pending.result.weak().same_channel(&attempt) - }); - } - } -} - impl kio::Task for Fetching { type Output = Result; @@ -4720,9 +4466,9 @@ impl kio::Task for Fetching { let group = ready!(kio::Task::poll(&mut **resume, waiter))?; return Poll::Ready(Ok(group.with_meter(self.stats.meter()))); } - let (state, fetch, sequence, frame_start, result) = ( + let (state, fills, sequence, frame_start, result) = ( &self.state, - &self.fetch, + &self.fills, self.sequence, self.frame_start, self.result.as_ref(), @@ -4768,7 +4514,7 @@ impl kio::Task for Fetching { // Never queued: no handler existed when the fetch was made. Fail fast while // that's still true; a handler that appeared since may yet fill the cache. // A sequence this track sent as a datagram says so, rather than a plain miss. - return match fetch.poll(waiter, |fetch| match fetch.has_handlers() { + return match fills.poll(waiter, |fills| match fills.has_handlers() { false => Poll::Ready(()), true => Poll::Pending, }) { @@ -4780,11 +4526,11 @@ impl kio::Task for Fetching { }; }; - // An answered attempt fails joined fetches the cache cannot cover. The channel + // An answered fill fails a joined fetch the cache cannot cover. The channel // closing without an answer means its handlers went away. - let (err, asked) = match result.outcome.poll(waiter, |outcome| match &outcome.rejected { - Some(err) => Poll::Ready((err.clone(), outcome.asked)), - None if outcome.accepted => Poll::Ready((Error::NotFound, None)), + let (err, asked) = match result.outcome().poll(waiter, |outcome| match outcome.refused(sequence) { + Some(err) => Poll::Ready((err.clone(), outcome.asked())), + None if outcome.is_accepted(sequence) => Poll::Ready((Error::NotFound, None)), None => Poll::Pending, }) { Poll::Ready(Ok(res)) => res, @@ -4798,7 +4544,7 @@ impl kio::Task for Fetching { } // Joined after a tighter budget went on the wire, which judged the group too old // for that budget, not ours: ask again with ours. Terminates, since the new - // attempt asks at least our budget. + // fill asks at least our budget. let looser = match (asked, self.max_delay) { (Some(asked), Some(ours)) => ours > asked, (Some(_), None) => true, @@ -4810,7 +4556,7 @@ impl kio::Task for Fetching { max_delay: self.max_delay, frame_start: self.frame_start, }; - self.result = queue(&self.fetch, self.sequence, &options); + self.result = queue(&self.fills, self.sequence, &options); return self.poll(waiter); } Poll::Ready(Err(err)) @@ -4964,7 +4710,7 @@ impl group::Expiry for GroupExpiry { let max_delay = max_delay.unwrap_or_else(|| { let mut max_delay = Duration::default(); let _ = self.subscription.poll(waiter, |subscription| { - max_delay = subscription.max_delay; + max_delay = subscription.budget(); Poll::<()>::Pending }); max_delay @@ -5139,14 +4885,27 @@ struct Cursor { seek_pending: BTreeMap, /// The drops a wire publisher forwards, once it first polls for one. drops: Option>, + /// What this cursor misses and asks fills for, once it first reads. + misses: Option>, } struct Peeked { group: group::Consumer, - index: Option, + taken: Taken, ordered: bool, } +/// Where a recv-order candidate came from, which taking it advances past. +#[derive(Clone, Copy)] +enum Taken { + /// The arrival walk, which moves to this index. + Walk(usize), + /// What a widening owes. + Owed, + /// What a fill answered. + Filled, +} + /// The sequences a [`DropCursor`] reports from: a cursor's span, narrowed to its window. struct Within<'a> { span: ops::Range, @@ -5274,7 +5033,7 @@ impl Cursor { // subsequent delivery stays in arrival order, including late datagrams. let newest = state.datagrams.iter().filter_map(|datagram| datagram.timestamp).max(); let start = newest.map_or(0, |newest| { - let cutoff = Duration::from(newest).saturating_sub(preferences.max_delay); + let cutoff = Duration::from(newest).saturating_sub(preferences.budget()); state .datagrams .iter() @@ -5312,6 +5071,7 @@ impl Cursor { stale: stats::Content::default(), seek_pending: BTreeMap::new(), drops: None, + misses: None, }; cursor.update_servable(); cursor @@ -5320,6 +5080,20 @@ impl Cursor { #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_drop(&mut self, waiter: &kio::Waiter) -> Poll> { self.sync(); + if self.drops.is_none() { + let mut drops = Box::new(DropCursor::new(&self.state.read())); + if let Some(misses) = &self.misses { + for run in misses.given_up().iter() { + drops.pending.insert(run.clone()); + } + } + self.drops = Some(drops); + } + if self.filter.refused.is_none() + && let Poll::Ready(Ok(drift)) = self.poll_drift(&self.servable, waiter) + { + self.probe(self.floor(), &drift, waiter); + } let within = Within { span: self.floor()..self.end_sequence.unwrap_or(u64::MAX), window: &self.filter.window, @@ -5547,7 +5321,7 @@ impl Cursor { fn poll_drift(&self, servable: &Servable, waiter: &kio::Waiter) -> Poll> { let mut max_delay = Duration::default(); let _ = self.subscription.poll_ref(waiter, |subscription| { - max_delay = subscription.max_delay; + max_delay = subscription.budget(); Poll::<()>::Pending }); self.poll(waiter, |state| { @@ -5574,7 +5348,7 @@ impl Cursor { if delivered.above_gap().is_none() { return; } - let max_delay = self.subscription.read().max_delay; + let max_delay = self.subscription.read().budget(); let state = self.state.read(); let budget = clamp_max_delay(max_delay, state.max_age_bound()); let edge = state.drift_edge(&self.servable); @@ -5616,9 +5390,65 @@ impl Cursor { })) } + /// Ask fills for what this cursor misses from `from`, and take in what they answered, + /// parking `waiter` on the fills still out. Whatever it gives up goes to the drop cursor. + fn probe(&mut self, from: u64, drift: &Drift, waiter: &kio::Waiter) { + let asking = { + let subscription = self.subscription.read(); + miss::Asking { + start: subscription.start(), + priority: subscription.priority, + max_delay: subscription.max_delay, + order: subscription.order, + } + }; + let fills = self.state.read().fills.clone(); + let (window, cap, revision) = (&self.filter.window, self.end_sequence, self.filter.revision); + let misses = self.misses.get_or_insert_with(Box::default); + let mut given_up = Vec::new(); + if misses.lowest().is_some() { + misses.retain(from, &admitted(window, from, cap)); + } + let asks = { + let state = self.state.read(); + misses.judge(&state, (from, revision, cap), || admitted(window, from, cap), drift, &mut given_up) + }; + misses.ask(&fills, &asks, &asking, &mut given_up); + loop { + let retry = misses.resolve(&self.state, asking.max_delay, &mut given_up, waiter); + if retry.is_empty() { + break; + } + misses.ask(&fills, &retry, &asking, &mut given_up); + } + if let Some(drops) = &mut self.drops { + for run in given_up { + drops.pending.insert(run); + } + } + } + + /// The lowest group a fill answered for this cursor, cached and not handed out yet. + fn next_filled(&mut self) -> Option { + let misses = self.misses.as_mut()?; + let mut given_up = Vec::new(); + let group = misses.next_filled(&self.state.read(), &mut given_up); + if let Some(drops) = &mut self.drops { + for run in given_up { + drops.pending.insert(run); + } + } + group + } + + /// Whether a fill still owes this cursor a group, so the end of the feed is not its end. + fn awaits_fill(&self) -> bool { + self.misses.as_ref().is_some_and(|misses| misses.is_waiting()) + } + fn poll_recv_group(&mut self, take: bool, waiter: &kio::Waiter) -> Poll>> { if let Some(peeked) = self.peeked.take().filter(|peeked| take && !peeked.ordered) { - self.advance_arrival(peeked.group.sequence, peeked.index); + self.advance_arrival(peeked.group.sequence, peeked.taken); peeked.group.cache_refresh(); return Poll::Ready(Ok(Some(self.with_expiry(peeked.group)))); } @@ -5630,6 +5460,7 @@ impl Cursor { // One scan for the whole poll, so walking a backlog off stays linear in its size. let drift = ready!(self.poll_drift(&self.servable, waiter))?; + self.probe(self.floor(), &drift, waiter); self.poll_recv_with(take, drift, waiter) } @@ -5645,15 +5476,20 @@ impl Cursor { // Re-offer what a widening newly covers ahead of the arrival walk, lowest first: // it is the oldest thing still owed. A group the walk has yet to reach comes // again from it, and the subscriber's delivered set drops the repeat. - let (consumer, next_index) = match self.next_owed() { - Some(producer) => { + let owed = match self.next_owed() { + Some(producer) => Some((producer, Taken::Owed)), + // A fill answered what the walk never reaches: backfill is not in it. + None => self.next_filled().map(|producer| (producer, Taken::Filled)), + }; + let (consumer, taken) = match owed { + Some((producer, taken)) => { let consumer = producer.consume(); // A re-offer is a delivery: stamp it like a fresh hand-out. Peeking is // not an access. if take { consumer.cache_refresh(); } - (consumer, None) + (consumer, taken) } None => { let floor = self.floor(); @@ -5665,6 +5501,10 @@ impl Cursor { continue; } let Some((producer, found_index)) = ready!(polled)? else { + // The feed ended, but a fill still owes a group. Its answer wakes us. + if self.awaits_fill() { + return Poll::Pending; + } return Poll::Ready(Ok(None)); }; let consumer = producer.consume(); @@ -5696,7 +5536,7 @@ impl Cursor { self.index = found_index + 1; continue; } - (consumer, Some(found_index + 1)) + (consumer, Taken::Walk(found_index + 1)) } }; @@ -5706,7 +5546,7 @@ impl Cursor { // A peek discards what cannot be delivered, but keeps its candidate at the // same cursor position until the reader chooses this copy. if take || stale { - self.advance_arrival(consumer.sequence, next_index); + self.advance_arrival(consumer.sequence, taken); } if stale { self.stale.add(consumer.content()); @@ -5722,7 +5562,7 @@ impl Cursor { } else { self.peeked = Some(Box::new(Peeked { group: consumer.clone(), - index: next_index, + taken, ordered: false, })); consumer @@ -5732,10 +5572,15 @@ impl Cursor { /// Move the arrival cursor past a group it read: from the walk, to `index`; from what /// a widening owes, out of the owed spans. - fn advance_arrival(&mut self, sequence: u64, index: Option) { - match index { - Some(index) => self.index = index, - None => self.filter.owed.remove(sequence..=sequence), + fn advance_arrival(&mut self, sequence: u64, taken: Taken) { + match taken { + Taken::Walk(index) => self.index = index, + Taken::Owed => self.filter.owed.remove(sequence..=sequence), + Taken::Filled => { + if let Some(misses) = &mut self.misses { + misses.take_filled(sequence); + } + } } } @@ -5790,6 +5635,7 @@ impl Cursor { let floor = next.max(self.floor()); // One scan for the whole poll, so walking a backlog off stays linear in its size. let drift = ready!(self.poll_drift(&self.servable, waiter))?; + self.probe(floor, &drift, waiter); self.poll_next_with(take, floor, drift, waiter) } @@ -5812,12 +5658,26 @@ impl Cursor { continue; } let Some(producer) = ready!(polled)? else { + // The feed ended, but a fill still owes a group. Its answer wakes us. + if self.awaits_fill() { + return Poll::Pending; + } // Deliberately no flush of `seek_pending` here: only a delivery commit // may count a conviction. This `None` can be an artifact of a floor // that will lower again. A conviction never committed is dropped // uncounted with the cursor. return Poll::Ready(Ok(None)); }; + // In order means a group a fill still owes below this one comes first. Its + // answer, or giving it up, wakes us. + if self + .misses + .as_ref() + .and_then(|misses| misses.lowest_wanted()) + .is_some_and(|wanted| wanted < producer.sequence) + { + return Poll::Pending; + } let group = producer.consume(); // The first group to arrive resolves a relative floor, which may land above it, and @@ -5857,7 +5717,7 @@ impl Cursor { } else { self.peeked = Some(Box::new(Peeked { group: group.clone(), - index: None, + taken: Taken::Owed, ordered: true, })); group @@ -6288,7 +6148,8 @@ impl Subscriber { /// Poll for where the source's feed starts, raised to this cursor's floor, once /// resolved; see [`Subscriber::poll_start`]. A feed starting below the floor serves the - /// floor's group too. `None` when the source declares none. + /// floor's group too, and a fill this cursor waits on starts it lower still. `None` + /// when the source declares none and no fill is out. #[cfg_attr(not(feature = "async"), allow(dead_code))] // pending: /quest/m1/public-model/serve.md pub(crate) fn poll_start(&mut self, waiter: &kio::Waiter) -> Poll> { @@ -6298,7 +6159,12 @@ impl Subscriber { }; let start = ready!(Consumer::poll_state_start(&cursor.state, waiter)); cursor.sync(); - Poll::Ready(start.map(|start| start.max(cursor.floor()))) + let start = start.map(|start| start.max(cursor.floor())); + let filling = cursor.misses.as_ref().and_then(|misses| misses.lowest()); + Poll::Ready(match (start, filling) { + (Some(start), Some(filling)) => Some(start.min(filling)), + (start, filling) => start.or(filling), + }) } /// The lowest group this subscriber's ranges name, as resolved here when it last read: diff --git a/rs/moq-net/src/model/track/demand.rs b/rs/moq-net/src/model/track/demand.rs index b415c026944..d447fd463c2 100644 --- a/rs/moq-net/src/model/track/demand.rs +++ b/rs/moq-net/src/model/track/demand.rs @@ -18,6 +18,7 @@ use ranges::Ranges; pub(super) struct Subscriptions { count: usize, priority: BTreeMap, + // Keyed by budget, so `None` counts as the widest of all. max_delay: BTreeMap, ranges: Ranges, descending: usize, @@ -32,7 +33,7 @@ pub(super) struct Subscriptions { } /// Everything the aggregate holds besides its ranges. -type Scalars = (u8, Duration, Order); +type Scalars = (u8, Option, Order); impl Subscriptions { pub(super) fn combined(&self) -> Option { @@ -56,7 +57,7 @@ impl Subscriptions { (self.count != 0).then(|| { ( *self.priority.last_key_value().unwrap().0, - *self.max_delay.last_key_value().unwrap().0, + Some(*self.max_delay.last_key_value().unwrap().0).filter(|budget| *budget != Duration::MAX), if self.descending == 0 { Order::Ascending } else { @@ -140,7 +141,7 @@ impl Subscriptions { let moved = old.map(|old| &old.ranges) != new.map(|new| &new.ranges); if let Some(new) = new { add(&mut self.priority, new.priority); - add(&mut self.max_delay, new.max_delay); + add(&mut self.max_delay, new.budget()); if moved { for range in &new.ranges { reshaped |= self.ranges.add(*range); @@ -151,7 +152,7 @@ impl Subscriptions { } if let Some(old) = old { remove(&mut self.priority, old.priority); - remove(&mut self.max_delay, old.max_delay); + remove(&mut self.max_delay, old.budget()); if moved { for range in &old.ranges { reshaped |= self.ranges.remove(*range); diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs new file mode 100644 index 00000000000..0ec4fd1150c --- /dev/null +++ b/rs/moq-net/src/model/track/fill.rs @@ -0,0 +1,717 @@ +//! Fills: the groups readers miss, asked of a [`Dynamic`](super::Dynamic) handler by range. +//! +//! A miss is a sequence a reader wants that the cache doesn't hold and the live feed will +//! never deliver. A reader joins the fill already covering it, or queues a new one, and a +//! handler serves each fill it takes as one bounded run: [`Fill::accept`] for each group it +//! has, [`Fill::drop_groups`] for the rest. + +use std::{ + collections::{HashMap, VecDeque}, + ops, + sync::Arc, + task::Poll, + time::Duration, +}; + +use rangemap::{RangeMap, RangeSet}; + +use super::{Info, Order, Position, TrackState, clamp_max_delay}; +use crate::{Error, Result, group}; + +/// The fills a track's readers asked for, queued or taken by a handler, under one lock. +#[derive(Default)] +pub(in crate::model) struct FillState { + // Live `Dynamic` handles: a fill queues only while one can take it. + handlers: usize, + next: u64, + // Every fill not yet ended, queued or taken, by id. + pending: HashMap, + // The queued subset, oldest first. + queue: VecDeque, + // The fill each unresolved sequence waits on, so a second reader joins it rather than + // asking again. + covers: RangeMap, +} + +/// One fill until it ends. +struct Pending { + start: Position, + end: u64, + priority: u8, + max_delay: Option, + order: Order, + queued: bool, + // Readers hold consumers, so it counts the fill's demand; the handler's copy says what + // became of each sequence. + result: kio::Producer, +} + +/// What became of a fill's sequences, shared with every reader that joined it. +#[derive(Default)] +// pending: /quest/m1/public-model/deferred.md +pub(crate) struct Outcome { + accepted: RangeSet, + // Why each refused run was refused, in the order the handler refused them. + refused: Vec<(ops::Range, Error)>, + // The budget the fill asked its handler for, so a looser reader can tell that an + // [`Error::Old`] was not its own. + asked: Option, +} + +impl Outcome { + /// Why `sequence` was refused, if it was. + pub(super) fn refused(&self, sequence: u64) -> Option<&Error> { + self.refused + .iter() + .rev() + .find(|(groups, _)| groups.contains(&sequence)) + .map(|(_, err)| err) + } + + pub(super) fn is_accepted(&self, sequence: u64) -> bool { + self.accepted.contains(&sequence) + } + + pub(super) fn asked(&self) -> Option { + self.asked + } + + /// What was answered within `wanted`: accepted, or refused with why and the budget + /// the fill asked with. + #[allow(clippy::type_complexity)] + pub(super) fn within( + &self, + wanted: &RangeSet, + ) -> Vec<(ops::Range, std::result::Result<(), (Error, Option)>)> { + let mut answers = Vec::new(); + for run in wanted.iter() { + for accepted in self.accepted.overlapping(run) { + answers.push((accepted.start.max(run.start)..accepted.end.min(run.end), Ok(()))); + } + for (refused, err) in &self.refused { + let groups = refused.start.max(run.start)..refused.end.min(run.end); + if !groups.is_empty() { + answers.push((groups, Err((err.clone(), self.asked)))); + } + } + } + answers + } + + fn resolved(&self) -> RangeSet { + let mut resolved = self.accepted.clone(); + for (groups, _) in &self.refused { + resolved.insert(groups.clone()); + } + resolved + } + + fn refuse(&mut self, groups: ops::Range, err: Error) { + if groups.is_empty() { + return; + } + self.refused.push((groups, err)); + } +} + +/// A reader's request: the run `start.group..end`, from `start.frame` in its first group. +pub(super) struct Ask { + pub start: Position, + pub end: u64, + pub priority: u8, + pub max_delay: Option, + pub order: Order, +} + +/// A reader's place in one fill: the part of it the reader wants. +pub(super) struct Join { + fills: kio::Shared, + id: u64, + /// The sequences of the fill this reader asked for. + pub groups: ops::Range, + // Counts this reader in the fill's demand; `None` only while dropping. + outcome: Option>, + attempt: kio::ProducerWeak, +} + +impl Join { + pub(super) fn outcome(&self) -> &kio::Consumer { + self.outcome.as_ref().expect("held until dropped") + } +} + +impl Drop for Join { + fn drop(&mut self) { + // Joins take the lock, so leaving under it is atomic with one: a joiner either keeps + // the fill wanted, or finds it withdrawn and asks afresh rather than joining a fill + // its handler is about to drop. + let mut fills = self.fills.lock(); + drop(self.outcome.take()); + if !self.attempt.is_used() && fills.pending.get(&self.id).is_some_and(|pending| self.is_ours(pending)) { + fills.withdraw(self.id); + } + } +} + +impl Join { + fn is_ours(&self, pending: &Pending) -> bool { + pending.result.weak().same_channel(&self.attempt) + } +} + +impl FillState { + pub(super) fn add_handler(&mut self) { + self.handlers += 1; + } + + /// Count a handler out. The last one leaving drops what it never took, so every reader + /// waiting on those fills finds them gone; a fill already taken stays with its handler. + pub(super) fn remove_handler(&mut self) { + self.handlers -= 1; + if self.handlers > 0 { + return; + } + for id in std::mem::take(&mut self.queue) { + self.end(id); + } + } + + pub(super) fn has_handlers(&self) -> bool { + self.handlers > 0 + } + + pub(super) fn has_queued(&self) -> bool { + !self.queue.is_empty() + } + + /// Join every fill covering `ask`, queueing a new one for each run none covers. Returns + /// the joins, and the runs nobody can fill: no handler is alive to take a new fill. + pub(super) fn join(&mut self, shared: &kio::Shared, ask: &Ask) -> (Vec, Vec>) { + let span = ask.start.group..ask.end; + let mut joins = Vec::new(); + let mut unfillable = Vec::new(); + if span.is_empty() { + return (joins, unfillable); + } + let covered: Vec<(ops::Range, u64)> = self + .covers + .overlapping(&span) + .map(|(groups, id)| (groups.start.max(span.start)..groups.end.min(span.end), *id)) + .collect(); + let gaps: Vec> = self.covers.gaps(&span).collect(); + for (groups, id) in covered { + let pending = self.pending.get_mut(&id).expect("a covered sequence names a pending fill"); + // Widened only while queued: once taken, the handler holds what it was asked. + if pending.queued { + pending.priority = pending.priority.max(ask.priority); + pending.max_delay = looser(pending.max_delay, ask.max_delay); + if groups.start == pending.start.group && ask.start.group == groups.start { + pending.start.frame = pending.start.frame.min(ask.start.frame); + } + } + joins.push(Join { + fills: shared.clone(), + id, + groups, + outcome: Some(pending.result.consume()), + attempt: pending.result.weak(), + }); + } + for groups in gaps { + if self.handlers == 0 { + unfillable.push(groups); + continue; + } + let id = self.next; + self.next += 1; + let start = match groups.start == ask.start.group { + true => ask.start, + false => Position::group(groups.start), + }; + let result = kio::Producer::::default(); + joins.push(Join { + fills: shared.clone(), + id, + groups: groups.clone(), + outcome: Some(result.consume()), + attempt: result.weak(), + }); + self.covers.insert(groups.clone(), id); + self.pending.insert( + id, + Pending { + start, + end: groups.end, + priority: ask.priority, + max_delay: ask.max_delay, + order: ask.order, + queued: true, + result, + }, + ); + self.queue.push_back(id); + } + (joins, unfillable) + } + + /// Take the oldest queued fill, still pending (joinable) until it ends. + fn pop(&mut self) -> Option { + let id = self.queue.pop_front()?; + let pending = self.pending.get_mut(&id).expect("a queued fill is pending"); + pending.queued = false; + Some(id) + } + + /// Drop fill `id`: nobody wants it, so it records nothing. + fn withdraw(&mut self, id: u64) { + let Some(pending) = self.pending.get(&id) else { + return; + }; + if pending.queued { + self.queue.retain(|queued| *queued != id); + } + self.end(id); + } + + /// The parts of `groups` routed to fill `id`. + fn routed(&self, id: u64, groups: &ops::Range) -> Vec> { + self.covers + .overlapping(groups) + .filter(|(_, covering)| **covering == id) + .map(|(range, _)| range.start.max(groups.start)..range.end.min(groups.end)) + .collect() + } + + /// Forget fill `id`, closing it for its readers. + fn end(&mut self, id: u64) { + let Some(pending) = self.pending.remove(&id) else { + return; + }; + for range in self.routed(id, &(pending.start.group..pending.end)) { + self.covers.remove(range); + } + } +} + +/// Stop routing readers wanting `groups` to fill `id`: they were answered. Reads first, so +/// an answer that changes nothing wakes no handler parked on the queue. +fn uncover(fills: &kio::Shared, id: u64, groups: ops::Range) { + let mut fills = fills.lock(); + let routed = fills.routed(id, &groups); + for range in routed { + fills.covers.remove(range); + } +} + +/// The more tolerant of two budgets: `None` is publisher max-age alone, which no explicit +/// budget can relax. +fn looser(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.max(b)), + _ => None, + } +} + +/// Pop the next queued fill, or surface the track's abort once none is queued. +pub(super) fn poll_requested( + state: &kio::Producer, + fills: &kio::Shared, + waiter: &kio::Waiter, +) -> Poll> { + // Prefer serving a queued fill, even if the track has since aborted. + if let Poll::Ready(mut guard) = fills.poll(waiter, |fills| match fills.has_queued() { + true => Poll::Ready(()), + false => Poll::Pending, + }) { + let id = guard.pop().expect("predicate guaranteed a fill"); + let pending = guard.pending.get(&id).expect("popped fill is pending"); + let (start, end, priority, max_delay, order) = ( + pending.start, + pending.end, + pending.priority, + pending.max_delay, + pending.order, + ); + let result = pending.result.clone(); + drop(guard); + // Built with the fills unlocked, so no other lock nests under theirs. + let inner = Inner { + state: state.clone(), + fills: fills.clone(), + id, + start, + end, + priority, + max_delay, + order, + result, + }; + // What it asked for decides whose `Old` it is. + if let Ok(mut outcome) = inner.result.write() { + outcome.asked = inner.max_delay; + } + return Poll::Ready(Ok(Fill { inner: Arc::new(inner) })); + } + + // No fill queued: surface a track abort so the handler loop can exit. + match state.poll_ref(waiter, |state| match &state.abort { + Some(err) => Poll::Ready(err.clone()), + None => Poll::Pending, + }) { + Poll::Ready(Ok(err)) => Poll::Ready(Err(err)), + Poll::Ready(Err(closed)) => Poll::Ready(Err(closed.abort.clone().unwrap_or(Error::Dropped))), + Poll::Pending => Poll::Pending, + } +} + +/// A run of groups readers miss, for a [`Dynamic`](super::Dynamic) handler to serve. +/// +/// It names `start.group` up to the exclusive [`end`](Self::end), the first group from +/// [`start`](Self::start)'s frame. The handler calls [`accept`](Self::accept) for each group +/// it has and [`drop_groups`](Self::drop_groups) for the rest. Dropping the fill names +/// whatever is left as dropped while a reader still wants it; once nobody does, the fill is +/// withdrawn and records nothing, so stop serving it when [`unused`](Self::unused) resolves. +pub struct Fill { + inner: Arc, +} + +/// The state a [`Fill`] and its per-group handles share. The last one dropping settles what +/// was left. +struct Inner { + state: kio::Producer, + fills: kio::Shared, + id: u64, + start: Position, + end: u64, + priority: u8, + max_delay: Option, + order: Order, + result: kio::Producer, +} + +impl Fill { + /// The first group to fill, and the frame within it to start from. + pub fn start(&self) -> Position { + self.inner.start + } + + /// The exclusive end group. + pub fn end(&self) -> u64 { + self.inner.end + } + + /// The most urgent delivery priority among the readers waiting on it. + pub fn priority(&self) -> u8 { + self.inner.priority + } + + /// The most tolerant age budget among the readers waiting on it, or `None` for the + /// publisher's max age alone. Each reader still enforces its own. + pub fn max_delay(&self) -> Option { + self.inner.max_delay + } + + /// Which end to fill first: [`Order::Descending`] is newest first. + pub fn order(&self) -> Order { + self.inner.order + } + + /// Insert group `sequence` into the track's cache, handing it to the readers waiting on + /// it, and return a [`group::Producer`] to fill. + /// + /// The group starts at [`start`](Self::start)'s frame when it is the first group, so the + /// frames written keep the indices they have in the group. `info` sets the track's + /// [`Info`] if it hasn't been accepted yet (a fetch with no live subscription), and is + /// ignored otherwise. Errors with [`Error::InvalidRange`] outside the fill, + /// [`Error::Duplicate`] if the group was already answered or cached, or the track's abort + /// error. + pub fn accept(&self, sequence: u64, info: impl Into>) -> Result { + self.inner.accept(sequence, info.into()) + } + + /// Name `groups` as never to be delivered: the source no longer has them. Readers stop + /// waiting for them, and the track records them so nobody asks again. + pub fn drop_groups(&self, groups: ops::Range) { + self.inner.drop_groups(groups, Error::NotFound); + } + + /// Whether a reader still waits on this fill. + pub fn is_used(&self) -> bool { + self.inner.result.is_used() + } + + /// Wait until no reader waits on this fill: it is withdrawn, so stop serving it. + #[cfg(feature = "async")] + pub async fn unused(&self) { + kio::wait(|waiter| self.poll_unused(waiter)).await + } + + /// Poll until no reader waits on this fill. + pub fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + self.inner.result.poll_unused(waiter).map(|_| ()) + } + + /// The next group to fill in the fill's order, skipping what was answered. + // private: capability gap + pub(crate) fn next(&self) -> Option { + loop { + let resolved = self.inner.result.read().resolved(); + let span = self.inner.start.group..self.inner.end; + let mut gaps = resolved.gaps(&span); + let sequence = match self.inner.order { + Order::Ascending => gaps.next().map(|gap| gap.start), + Order::Descending => gaps.last().map(|gap| gap.end - 1), + }?; + // The live feed may have delivered it since: answered by the cache, not the source. + let cached = self + .inner + .state + .read() + .covering_group(sequence, self.inner.frame_start(sequence)) + .is_some(); + if !cached { + return Some(sequence); + } + let Ok(mut outcome) = self.inner.result.write() else { + return Some(sequence); + }; + outcome.accepted.insert(sequence..sequence + 1); + drop(outcome); + uncover(&self.inner.fills, self.inner.id, sequence..sequence + 1); + } + } + + /// A handle on group `sequence` alone, for a session serving the fill one group at a time. + // private: capability gap + pub(crate) fn group(&self, sequence: u64) -> GroupFill { + GroupFill { + inner: self.inner.clone(), + sequence, + frame_start: match sequence == self.inner.start.group { + true => self.inner.start.frame, + false => 0, + }, + } + } +} + +impl Inner { + fn contains(&self, sequence: u64) -> bool { + (self.start.group..self.end).contains(&sequence) + } + + fn frame_start(&self, sequence: u64) -> u64 { + match sequence == self.start.group { + true => self.start.frame, + false => 0, + } + } + + fn accept(&self, sequence: u64, info: Option) -> Result { + if !self.contains(sequence) { + return Err(Error::InvalidRange); + } + let Some(end) = sequence.checked_add(1) else { + return Err(Error::InvalidRange); + }; + if self.result.read().resolved().contains(&sequence) { + return Err(Error::Duplicate); + } + // Cache the group before answering: readers resolve an accept through the cache. + let res = TrackState::modify(&self.state) + .and_then(|mut state| state.insert_group_request(sequence, self.frame_start(sequence), info)); + if let Ok(mut outcome) = self.result.write() { + outcome.accepted.insert(sequence..end); + } + uncover(&self.fills, self.id, sequence..end); + res + } + + /// Refuse `groups` with `err`, recording them in the track's drops when the source no + /// longer has them, which binds every reader. + fn drop_groups(&self, groups: ops::Range, err: Error) { + let groups = groups.start.max(self.start.group)..groups.end.min(self.end); + if groups.is_empty() { + return; + } + let unresolved = self.unresolved(groups); + if unresolved.is_empty() { + return; + } + if let Ok(mut state) = TrackState::modify(&self.state) { + for groups in unresolved.iter() { + state.drop_groups(groups.clone()); + } + } + self.refuse(&unresolved, err); + } + + /// The part of `groups` nothing answered yet. + fn unresolved(&self, groups: ops::Range) -> RangeSet { + let resolved = self.result.read().resolved(); + resolved.gaps(&groups).collect() + } + + fn refuse(&self, groups: &RangeSet, err: Error) { + if groups.is_empty() { + return; + } + if let Ok(mut outcome) = self.result.write() { + for groups in groups.iter() { + outcome.refuse(groups.clone(), err.clone()); + } + } + for groups in groups.iter() { + uncover(&self.fills, self.id, groups.clone()); + } + } + + /// Whether an [`Error::Old`] answer is the publisher's max age, which binds every reader, + /// rather than this fill's own budget, which a more tolerant reader may exceed. + fn old_binds_everyone(&self) -> bool { + let Some(max_delay) = self.max_delay else { + return true; + }; + let bound = self.state.read().max_age_bound(); + bound.is_some_and(|bound| clamp_max_delay(max_delay, Some(bound)) >= bound) + } + + /// Refuse group `sequence` with `err`, as its source answered. + fn reject(&self, sequence: u64, err: Error) { + let Some(end) = sequence.checked_add(1) else { + return; + }; + match err { + // The source no longer has it. + Error::NotFound | Error::NotFetchable => self.drop_groups(sequence..end, err), + // Stale under the fill's budget, and so is every group older than it: a timed + // track's starts never rewind. Only the publisher's max age binds every reader; + // under the fill's own budget they are withdrawn, for a reader with a wider one. + Error::Old => { + let older = self.start.group..end; + match self.old_binds_everyone() { + true => self.drop_groups(older, Error::Old), + false => { + let unresolved = self.unresolved(older); + self.refuse(&unresolved, Error::Old); + } + } + } + // Anything else says nothing about the group: refused for now, recorded nowhere. + err => { + let unresolved = self.unresolved(sequence..end); + self.refuse(&unresolved, err); + } + } + } +} + +impl Drop for Inner { + fn drop(&mut self) { + // Whatever the handler left unanswered is named dropped, but only while a reader still + // waits: once demand left, the fill was withdrawn and records nothing. A group cached + // since (the live feed delivered it) is no drop at all. + let left = self.unresolved(self.start.group..self.end); + if !left.is_empty() && self.result.is_used() { + let mut dropped = RangeSet::new(); + if let Ok(mut state) = TrackState::modify(&self.state) { + for groups in left.iter() { + let mut from = groups.start; + let cached: Vec = state + .lookup + .range(groups.clone()) + .filter(|(_, slot)| !slot.is_aborted()) + .map(|(sequence, _)| *sequence) + .collect(); + for sequence in cached { + if from < sequence { + dropped.insert(from..sequence); + } + from = sequence + 1; + } + if from < groups.end { + dropped.insert(from..groups.end); + } + } + for groups in dropped.iter() { + state.drop_groups(groups.clone()); + } + } + if let Ok(mut outcome) = self.result.write() { + for groups in left.iter() { + outcome.refuse(groups.clone(), Error::Dropped); + } + } + } + self.fills.lock().end(self.id); + } +} + +/// One group of a [`Fill`], for a session serving it a group at a time. Answering it answers +/// that group of the fill; dropping it unanswered leaves the group to the fill. +// private: capability gap +pub(crate) struct GroupFill { + inner: Arc, + sequence: u64, + frame_start: u64, +} + +impl GroupFill { + /// The group sequence to fetch. + pub(crate) fn sequence(&self) -> u64 { + self.sequence + } + + /// The delivery priority the readers asked for. + pub(crate) fn priority(&self) -> u8 { + self.inner.priority + } + + /// The widest budget among the readers, for a handler that forwards it. + pub(crate) fn max_delay(&self) -> Option { + self.inner.max_delay + } + + /// The first frame of the group the readers want; 0 is the whole group. + pub(crate) fn frame_start(&self) -> u64 { + self.frame_start + } + + /// Poll until no reader waits on the fill. + pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + self.inner.result.poll_unused(waiter).map(|_| ()) + } + + /// The fill's readers, for a session keeping a group alive while any still wait. + pub(crate) fn joined(&self) -> kio::Producer { + self.inner.result.clone() + } + + /// The track the fill serves. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn track(&self) -> kio::Producer { + self.inner.state.clone() + } + + /// Insert the group into the cache, as [`Fill::accept`]. + pub(crate) fn accept(self, info: impl Into>) -> Result { + self.inner.accept(self.sequence, info.into()) + } + + /// Refuse the group with the source's answer. A miss names it dropped, and + /// [`Error::Old`] every older group of the fill too. + pub(crate) fn reject(self, err: Error) { + self.inner.reject(self.sequence, err); + } + + /// Declare the track's exclusive final sequence, as the source answering this group + /// reported it. A no-op once the track declared an end, or holds a later group. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn finish_track_at(&self, final_sequence: u64) { + if let Ok(mut state) = TrackState::modify(&self.inner.state) + && state.end.is_none() + { + let _ = state.set_end(Position::group(final_sequence)); + } + } +} diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs new file mode 100644 index 00000000000..0f6b22cec78 --- /dev/null +++ b/rs/moq-net/src/model/track/miss.rs @@ -0,0 +1,386 @@ +//! A reader's misses: the sequences it reads that the cache doesn't hold and the live feed +//! will never deliver, asked of the track's fills until each arrives or is given up. +//! +//! Nothing above the newest group the feed produced is a miss, since it arrives live, so a +//! fill is bounded by construction. Below it, a hole is a miss once the feed is past it: +//! under where the feed starts, or accounted for by the arrival ledger without being cached +//! (evicted, or a gap the feed skipped). A hole the ledger still waits for is deferred and +//! judged again as the feed moves. + +use std::{ + collections::BTreeSet, + ops::{self, RangeInclusive}, + task::Poll, + time::Duration, +}; + +use rangemap::{RangeInclusiveSet, RangeSet}; + +use super::fill::{Ask, FillState, Join}; +use super::{Drift, Order, Position, TrackState}; +use crate::Error; + +/// What one reader asks its fills with. +pub(super) struct Asking { + /// Where the reader's own start falls, for the frame of a run beginning at its group. + pub start: Option, + pub priority: u8, + pub max_delay: Option, + pub order: Order, +} + +/// The misses of one cursor, judged as what it reads or the feed moves. +#[derive(Default)] +pub(super) struct Misses { + /// What the last probe judged against: the span read, the feed's newest group, and + /// where the feed starts and readers start. A probe that sees the same skips judging. + at: Option<(u64, u64, Option, Option, Option, Option, u64)>, + /// The sequences judged already, within what the reader reads up to the newest group. + judged: RangeSet, + /// Holes the live feed may still deliver, judged again on every probe. + deferred: RangeSet, + /// The fills this reader waits on. + joins: Vec, + /// The sequences of `joins` still unanswered. + wanted: RangeSet, + /// Answered by a fill and cached, not handed out yet. + filled: BTreeSet, + /// Never asked again: named dropped to the reader. + given_up: RangeSet, +} + +/// A run of misses the reader gives up, for the drop cursor to name. +pub(super) type GivenUp = Vec>; + +impl Misses { + /// The lowest sequence still to come from a fill: asked, or answered and not handed out. + pub fn lowest(&self) -> Option { + let wanted = self.wanted.iter().next().map(|run| run.start); + let filled = self.filled.first().copied(); + match (wanted, filled) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + } + } + + /// Whether a fill still owes this reader a group. + pub fn is_waiting(&self) -> bool { + !self.wanted.is_empty() + } + + /// The lowest sequence asked and not answered yet. + pub fn lowest_wanted(&self) -> Option { + self.wanted.iter().next().map(|run| run.start) + } + + /// Everything given up so far, for a drop cursor created after. + pub fn given_up(&self) -> &RangeSet { + &self.given_up + } + + /// The lowest answered group still cached, forgetting one evicted before it was read. + pub fn next_filled(&mut self, state: &TrackState, given_up: &mut GivenUp) -> Option { + while let Some(&sequence) = self.filled.first() { + if let Some(slot) = state.lookup.get(&sequence).filter(|slot| !slot.is_aborted()) { + return Some(slot.serving().clone()); + } + self.filled.remove(&sequence); + self.give_up(sequence..sequence + 1, given_up); + } + None + } + + /// Take `sequence` as handed out. + pub fn take_filled(&mut self, sequence: u64) { + self.filled.remove(&sequence); + } + + /// Forget what the reader no longer reads: below `from`, or outside `admitted`. A fill + /// nobody waits on is withdrawn once its last join goes. + pub fn retain(&mut self, from: u64, admitted: &RangeInclusiveSet) { + if self.wanted.is_empty() && self.filled.is_empty() { + return; + } + let outside = |sequence: u64| sequence < from || !admitted.contains(&sequence); + self.filled.retain(|sequence| !outside(*sequence)); + let mut kept = RangeSet::new(); + for run in self.wanted.iter() { + for inside in clip(run.clone(), from, admitted) { + kept.insert(inside); + } + } + self.wanted = kept; + let wanted = &self.wanted; + self.joins.retain(|join| wanted.overlaps(&join.groups)); + } + + /// Take in what the joined fills answered, parking `waiter` on the rest. Returns the + /// runs to ask again: refused as too old for a budget tighter than this reader's. + pub fn resolve( + &mut self, + state: &kio::Consumer, + max_delay: Option, + given_up: &mut GivenUp, + waiter: &kio::Waiter, + ) -> RangeSet { + let mut retry = RangeSet::new(); + let mut i = 0; + while i < self.joins.len() { + let join = &self.joins[i]; + let wanted: RangeSet = self + .wanted + .overlapping(&join.groups) + .map(|run| run.start.max(join.groups.start)..run.end.min(join.groups.end)) + .collect(); + if wanted.is_empty() { + self.joins.swap_remove(i); + continue; + } + let answers = match join.outcome().poll(waiter, |outcome| { + let answered = outcome.within(&wanted); + match answered.is_empty() { + true => Poll::Pending, + false => Poll::Ready(answered), + } + }) { + Poll::Ready(Ok(answers)) => answers, + // Ended without answering the rest: its handlers went away. Whatever the cache + // holds by now is served from it, and the rest is given up, as for an accept. + Poll::Ready(Err(closed)) => { + let mut answers = closed.within(&wanted); + let mut left = wanted.clone(); + for (groups, _) in &answers { + left.remove(groups.clone()); + } + answers.extend(left.iter().map(|run| (run.clone(), Ok(())))); + answers + } + Poll::Pending => { + i += 1; + continue; + } + }; + for (groups, answer) in answers { + self.wanted.remove(groups.clone()); + match answer { + Ok(()) => { + let state = state.read(); + for sequence in groups { + match state.lookup.get(&sequence).is_some_and(|slot| !slot.is_aborted()) { + true => { + self.filled.insert(sequence); + } + false => self.give_up(sequence..sequence + 1, given_up), + } + } + } + Err((Error::Old, asked)) if looser(max_delay, asked) => retry.insert(groups), + // A drop the track recorded is named by the drop cursor already. + Err(_) => self.give_up(groups, given_up), + } + } + // Judged again: the same join may have more to answer, or park the waiter now. + } + retry + } + + /// Judge the holes in `target` the last probe has not, returning the runs to ask a + /// fill for. Gives up a run already stale under this reader's budget. + pub fn judge( + &mut self, + state: &TrackState, + key: (u64, u64, Option), + target: impl FnOnce() -> RangeInclusiveSet, + drift: &Drift, + given_up: &mut GivenUp, + ) -> RangeSet { + let mut asks = RangeSet::new(); + // The feed's start is unknown until the serving session resolves it, and readers + // take nothing from a cache that does not reflect the feed. + if state.start_pending || !state.readable() { + return asks; + } + let Some(newest) = state.newest_produced else { + return asks; + }; + let at = ( + key.0, + key.1, + key.2, + Some(newest), + state.live_floor, + state.start_sequence, + state.drops_revision, + ); + if self.at == Some(at) && self.deferred.is_empty() { + return asks; + } + self.at = Some(at); + + let low = state.live_floor.unwrap_or(0); + let mut span = RangeSet::new(); + if low <= newest { + for run in target().overlapping(&(low..=newest)) { + span.insert((*run.start()).max(low)..(*run.end()).min(newest) + 1); + } + } + // What left the span is forgotten, so it is judged afresh if it comes back. + let judged: RangeSet = self + .judged + .iter() + .flat_map(|run| span.overlapping(run).map(move |s| s.start.max(run.start)..s.end.min(run.end))) + .collect(); + let mut candidates: RangeSet = span.iter().flat_map(|run| judged.gaps(run)).collect(); + for run in std::mem::take(&mut self.deferred).iter() { + for s in span.overlapping(run) { + candidates.insert(s.start.max(run.start)..s.end.min(run.end)); + } + } + self.judged = judged; + for run in candidates.iter() { + self.judged.insert(run.clone()); + } + + let start = state.start_sequence.or(state.arrivals.first()); + let datagrams: BTreeSet = state.datagrams.iter().map(|datagram| datagram.sequence).collect(); + for run in candidates.iter() { + for hole in holes(state, run.clone()) { + for hole in self.unclaimed(state, hole) { + let Some(start) = start else { + self.deferred.insert(hole); + continue; + }; + // Below where the feed starts: it will never come live. + if hole.start < start { + asks.insert(hole.start..hole.end.min(start)); + } + let above = hole.start.max(start)..hole.end; + if above.is_empty() { + continue; + } + // Accounted for by the feed and not cached: evicted, or skipped. A buffered + // datagram arrived and is served as one. + let mut from = above.start; + for arrived in state.arrivals.accounted(above.clone()) { + if from < arrived.start { + self.deferred.insert(from..arrived.start); + } + asks.insert(arrived.clone()); + for sequence in datagrams.range(arrived.clone()) { + asks.remove(*sequence..*sequence + 1); + } + from = arrived.end; + } + if from < above.end { + self.deferred.insert(from..above.end); + } + } + } + } + + // Starts never rewind, so a run whose newest group is stale under this reader's + // budget is stale throughout. + let stale: Vec> = asks + .iter() + .filter(|run| state.drifted(run.end - 1, &drift.edge, drift.budget)) + .cloned() + .collect(); + for run in stale { + asks.remove(run.clone()); + self.give_up(run, given_up); + } + asks + } + + /// Ask the fills for `runs`, giving up what no handler can take. + pub fn ask(&mut self, fills: &kio::Shared, runs: &RangeSet, asking: &Asking, given_up: &mut GivenUp) { + for run in runs.iter() { + let frame = asking + .start + .filter(|start| start.group == run.start) + .map_or(0, |start| start.frame); + let ask = Ask { + start: Position { + group: run.start, + frame, + }, + end: run.end, + priority: asking.priority, + max_delay: asking.max_delay, + order: asking.order, + }; + let (joins, unfillable) = fills.lock().join(fills, &ask); + self.wanted.insert(run.clone()); + for groups in unfillable { + self.wanted.remove(groups.clone()); + self.give_up(groups, given_up); + } + self.joins.extend(joins); + } + } + + /// The part of `hole` nobody has a verdict on yet. + fn unclaimed(&self, state: &TrackState, hole: ops::Range) -> Vec> { + let mut left = RangeSet::new(); + left.insert(hole); + for claimed in [&state.drops, &self.given_up, &self.wanted] { + for run in claimed.iter() { + left.remove(run.clone()); + } + } + for sequence in &self.filled { + left.remove(*sequence..*sequence + 1); + } + left.iter().cloned().collect() + } + + fn give_up(&mut self, groups: ops::Range, given_up: &mut GivenUp) { + if groups.is_empty() { + return; + } + self.given_up.insert(groups.clone()); + given_up.push(groups); + } +} + +/// The runs of `run` the cache holds nothing servable for. +fn holes(state: &TrackState, run: ops::Range) -> Vec> { + let mut holes = Vec::new(); + let mut from = run.start; + for (&sequence, slot) in state.lookup.range(run.clone()) { + if slot.is_aborted() { + continue; + } + if from < sequence { + holes.push(from..sequence); + } + from = sequence + 1; + } + if from < run.end { + holes.push(from..run.end); + } + holes +} + +/// The parts of `run` at or above `from` inside `admitted`. +fn clip(run: ops::Range, from: u64, admitted: &RangeInclusiveSet) -> Vec> { + let run = run.start.max(from)..run.end; + if run.is_empty() { + return Vec::new(); + } + let inclusive: RangeInclusive = run.start..=run.end - 1; + admitted + .overlapping(&inclusive) + .map(|inside| (*inside.start()).max(run.start)..(*inside.end()).min(run.end - 1) + 1) + .collect() +} + +/// Whether `ours` tolerates more than the budget a fill `asked` with, so its [`Error::Old`] +/// was not this reader's. +fn looser(ours: Option, asked: Option) -> bool { + match (asked, ours) { + (Some(asked), Some(ours)) => ours > asked, + (Some(_), None) => true, + (None, _) => false, + } +} From feb903ed0157c2a478b306d98ee53be1850effbc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 22:35:04 -0700 Subject: [PATCH 03/48] wip: misses judged against the arrival ledger --- rs/moq-net/src/ietf/publisher.rs | 2 +- rs/moq-net/src/ietf/subscriber.rs | 18 +- rs/moq-net/src/lite/publisher.rs | 31 +- rs/moq-net/src/lite/subscriber.rs | 20 +- rs/moq-net/src/model/broadcast.rs | 2 +- rs/moq-net/src/model/origin.rs | 2 +- rs/moq-net/src/model/resume.rs | 20 +- rs/moq-net/src/model/track.rs | 267 ++++++++++-------- rs/moq-net/src/model/track/demand.rs | 2 +- rs/moq-net/src/model/track/fill.rs | 58 ++-- rs/moq-net/src/model/track/miss.rs | 141 ++++++--- rs/moq-net/tests/integration/fetch_cancel.rs | 17 +- rs/moq-net/tests/integration/fetch_caps.rs | 12 +- .../tests/integration/subscriber_max_age.rs | 2 +- rs/moq-net/tests/integration/untimed.rs | 4 +- rs/moq-net/tests/loom.rs | 8 +- 16 files changed, 354 insertions(+), 252 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 2e8d64d1082..4c939ab4dae 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -10137,7 +10137,7 @@ mod serve_tests { let request = h._broadcast.reserve_track("cam").unwrap(); let dynamic = request.dynamic(); let fetching = request.consume().fetch_group(4, None); - let mut fetched = dynamic.requested_group().await.unwrap().accept(None).unwrap(); + let mut fetched = dynamic.requested_fill_group().await.unwrap().accept(None).unwrap(); for object in 0..2 { fetched.write_frame(None, format!("4-{object}").into_bytes()).unwrap(); } diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index b7dfaeadaa3..c1a3b7f5be7 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -7107,7 +7107,7 @@ mod tests { let requested = track.dynamic(); let mut pending = Box::pin(track.consume().fetch_group(0, None)); assert!(futures::poll!(pending.as_mut()).is_pending()); - let group = requested.requested_group().await.expect("group request"); + let group = requested.requested_fill_group().await.expect("group request"); fetch = Some((track, pending)); moq_clock::sim::spawn(subscriber.run_group_fetch( Path::new("broadcast").to_owned(), @@ -14713,8 +14713,8 @@ mod stitch_tests { let dynamic = self.track.dynamic(); let mut fetch = Box::pin(self.track.consume().fetch_group(SEQUENCE, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("group request"); - let track = request.state.clone(); + let request = dynamic.requested_fill_group().await.expect("group request"); + let track = request.track(); let producer = request.accept(None).unwrap(); let consumer = producer.consume(); let slot = kio::Producer::new(GroupFetch::Ready { @@ -15313,8 +15313,8 @@ mod joining_fetch_tests { let consumer = track.consume(); let mut fetch = Box::pin(consumer.fetch_group(GROUP, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("group request"); - let outcome = request.result.clone(); + let request = dynamic.requested_fill_group().await.expect("group request"); + let outcome = request.joined(); let mut run = Box::pin(subscriber.clone().run_group_fetch( Path::new("broadcast").to_owned(), "video".into(), @@ -15335,7 +15335,7 @@ mod joining_fetch_tests { "{version:?}: unanswered FETCH stays alive" ); assert!(matches!( - outcome.read().rejected, + outcome.read().refused(GROUP).cloned(), Some(Error::Cancel | Error::Unroutable) )); } else { @@ -15556,7 +15556,7 @@ mod joining_fetch_tests { let consumer = track.consume(); let mut fetch = std::pin::pin!(consumer.fetch_group(GROUP, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("no group requested"); + let request = dynamic.requested_fill_group().await.expect("no group requested"); subscriber .clone() @@ -15623,7 +15623,7 @@ mod joining_fetch_tests { let mut fetch = std::pin::pin!(consumer.fetch_group(GROUP, group::Fetch::default().with_frame_start(START))); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("no group requested"); + let request = dynamic.requested_fill_group().await.expect("no group requested"); subscriber .clone() @@ -15738,7 +15738,7 @@ mod joining_fetch_tests { let consumer = track.consume(); let mut fetch = std::pin::pin!(consumer.fetch_group(GROUP, group::Fetch::default().with_frame_start(START))); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("no group requested"); + let request = dynamic.requested_fill_group().await.expect("no group requested"); let serving = moq_clock::sim::spawn(subscriber.clone().run_group_fetch( Path::new("broadcast").to_owned(), diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 9d14651c18c..e493b3d2096 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -98,14 +98,11 @@ pub(super) const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; /// /// A version without the field decodes as [`Duration::ZERO`], which is /// indistinguishable from a peer genuinely asking for the live edge. Serving that -/// as real time would discard backlog a legacy subscriber never declined, so fall -/// back to a window wide enough not to drop and leave enforcement to the receiver, -/// exactly as the IETF path does for the same reason. +/// as real time would discard backlog a legacy subscriber never declined, so serve the +/// publisher's max age alone and leave enforcement to the receiver, exactly as the IETF +/// path does for the same reason. fn serving_max_delay(version: Version, requested: Duration) -> Option { - match version.carries_max_delay() { - true => Some(requested), - false => Some(Duration::from_millis(MAX_SAFE_DELAY_MS)), - } + version.carries_max_delay().then_some(requested) } /// Position a subscription's read cursor for the wire serving it. @@ -4980,17 +4977,14 @@ mod serve_group_test { fn a_version_without_the_field_serves_a_non_dropping_budget() { for version in [Version::Lite01, Version::Lite02] { let max_delay = serving_max_delay(version, Duration::ZERO); - assert!( - max_delay >= Duration::from_secs(86_400), - "{version:?} must not be served as real time: {max_delay:?}" - ); + assert_eq!(max_delay, None, "{version:?} must not be served as real time"); } // A version that does carry it is taken at its word, zero included. - assert_eq!(serving_max_delay(Version::Lite05, Duration::ZERO), Duration::ZERO); + assert_eq!(serving_max_delay(Version::Lite05, Duration::ZERO), Some(Duration::ZERO)); assert_eq!( serving_max_delay(Version::Lite05, Duration::from_secs(3)), - Duration::from_secs(3) + Some(Duration::from_secs(3)) ); } @@ -6070,7 +6064,9 @@ mod serve_group_test { drop(track); run.await.unwrap(); } - assert_eq!(drops(&mut writer, &log, version).await, [(4, 4), (0, 1), (3, 3)]); + // The feed gave up on 0, 1 and 3 before the update, so they are misses named as the + // cursor judges them, ahead of the stale group. + assert_eq!(drops(&mut writer, &log, version).await, [(0, 1), (3, 3), (4, 4)]); } /// After a raised start, lowering it again restarts the gaps below the raised floor, not @@ -6117,7 +6113,8 @@ mod serve_group_test { track.finish_at(track::Position::group(6)).unwrap(); drop(track); kio::wait(|waiter| run.poll(&mut writer, waiter)).await.unwrap(); - assert_eq!(drops(&mut writer, &log, version).await, [(1, 4)]); + // The feed gives up on 2..5 once its grace passes, a miss nothing fills. + assert_eq!(drops(&mut writer, &log, version).await, [(2, 4), (1, 1)]); } /// A raised floor sends no early drop: a later update may lower it again, and the @@ -7718,12 +7715,12 @@ mod tests { } } let _request = groups - .requested_group() + .requested_fill_group() .now_or_never() .expect("one shared fetch") .unwrap(); assert!( - groups.requested_group().now_or_never().is_none(), + groups.requested_fill_group().now_or_never().is_none(), "same group coalesces in the model" ); assert_eq!( diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index c606cff201c..936196f4aa4 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1953,12 +1953,12 @@ mod tests { const VERSION: Version = Version::Lite05; /// A pre-lite-05 relay's track, untimed, with a fetch of `sequence` queued on it. - fn feed_fetch(sequence: u64) -> (track::Producer, kio::Pending, group::Request) { + fn feed_fetch(sequence: u64) -> (track::Producer, kio::Pending, track::GroupFill) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(std::sync::Arc::new(crate::broadcast::Info::default()), "video", info); let dynamic = track.dynamic(); let fetching = track.consume().fetch_group(sequence, None); - let req = dynamic.requested_group().now_or_never().expect("queued").unwrap(); + let req = dynamic.requested_fill_group().now_or_never().expect("queued").unwrap(); (track, fetching, req) } @@ -2127,7 +2127,7 @@ mod tests { /// Many group fetches on one track share model state, but never share readiness. fn feed_batch( sequences: impl IntoIterator, - ) -> (track::Producer, Vec>, Vec) { + ) -> (track::Producer, Vec>, Vec) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(Arc::new(crate::broadcast::Info::default()), "video", info); let dynamic = track.dynamic(); @@ -2135,7 +2135,7 @@ mod tests { let mut requests = Vec::new(); for sequence in sequences { callers.push(track.consume().fetch_group(sequence, None)); - requests.push(dynamic.requested_group().now_or_never().expect("queued").unwrap()); + requests.push(dynamic.requested_fill_group().now_or_never().expect("queued").unwrap()); } (track, callers, requests) } @@ -2329,7 +2329,7 @@ mod tests { drop(caller); let next = track.consume().fetch_group(1, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("replacement queued") .unwrap(); @@ -2346,7 +2346,7 @@ mod tests { #[ignore = "release benchmark, run by nightly"] async fn benchmark_feed_fetch_wakes() { struct ScanFetches { - held: Vec<(group::Request, track::Consumer)>, + held: Vec<(track::GroupFill, track::Consumer)>, /// Armed at the earliest instant the feed's ledger gives up on a held group, so one /// that never arrives still fails. fold: crate::time::Deadline, @@ -2382,7 +2382,7 @@ mod tests { let tail = active.tail.read(); let mut fold: Option = None; for (req, feed) in std::mem::take(&mut self.held) { - if req.demand().poll_unused(waiter).is_ready() { + if req.poll_unused(waiter).is_ready() { continue; } let sequence = req.sequence(); @@ -6922,15 +6922,11 @@ impl ServeLoop { active.served = Some(start.group); // The groups the SUBSCRIBE asked for below it are not // waited for, whatever the demand asks later. One that - // still arrives is delivered. - // Recorded as dropped too, so a reader asking for them - // later is told so rather than fetching what the publisher - // already gave up. + // still arrives is delivered, so they are not dropped. if let Some(requested) = active.requested { if let Ok(mut tail) = active.tail.write() { tail.account(requested.group..start.group, serve.subscriber.runtime.now()); } - self.serving.drop_groups(requested.group..start.group); } } // The publisher will never send these groups, so they diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index c84c445b631..15683deeec2 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -2073,7 +2073,7 @@ mod test { let dynamic = request.dynamic(); let track = consumer.track("track1").unwrap(); let pending_fetch = track.fetch_group(0, None); - let fetch = dynamic.requested_group().await.unwrap(); + let fetch = dynamic.requested_fill_group().await.unwrap(); let mut group = fetch.accept(crate::track::Info::timed()).unwrap(); group.finish().unwrap(); pending_fetch.await.unwrap(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index dcea246547d..059135e772e 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -8706,7 +8706,7 @@ mod tests { let fetched = Arc::new(std::sync::Mutex::new(Vec::::new())); let serving = fetched.clone(); let _fetching = moq_clock::sim::spawn(async move { - while let Ok(request) = fetches.requested_group().await { + while let Ok(request) = fetches.requested_fill_group().await { let mut group = request.accept(crate::track::Info::timed()).unwrap(); for frame in [b"a", b"b", b"c"] { group.write_frame(crate::Timestamp::ZERO, frame.as_ref()).unwrap(); diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 88946f7a03f..d24e2a3d368 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1615,7 +1615,7 @@ mod test { open.abort(Error::SessionClosed).unwrap(); a.abort(Error::SessionClosed).unwrap(); assert!(held.poll_read_frame(&waiter).is_pending()); - let Poll::Ready(request) = dynamic.poll_requested_group(&waiter) else { + let Poll::Ready(request) = dynamic.poll_requested_fill_group(&waiter) else { panic!("recovery fetch") }; request.unwrap().reject(Error::NotFound); @@ -1810,7 +1810,7 @@ mod test { routes.serve(b.consume()); assert_eq!(read(&mut reading), Some(b"b".to_vec())); assert!(reading.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!((request.sequence(), request.frame_start()), (0, 2)); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(2), b"c".as_ref()).unwrap(); @@ -1821,7 +1821,7 @@ mod test { let whole = [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]; assert_eq!(read_cached(&b.consume(), 0), whole); assert_eq!(read_cached(&logical.consume(), 0), whole); - assert!(dynamic.requested_group().now_or_never().is_none(), "no second fetch"); + assert!(dynamic.requested_fill_group().now_or_never().is_none(), "no second fetch"); } /// A replaced route that only stalled lends the replacement its head the same way. @@ -1843,7 +1843,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(request.frame_start(), 1); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(1), b"b".as_ref()).unwrap(); @@ -1876,7 +1876,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(fetched.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(request.frame_start(), 1); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(1), b"b".as_ref()).unwrap(); @@ -1885,7 +1885,7 @@ mod test { assert_eq!(read(&mut fetched), None); let _whole = b.consume().fetch_group(0, None); assert!( - dynamic.requested_group().now_or_never().is_some(), + dynamic.requested_fill_group().now_or_never().is_some(), "the head was not kept" ); } @@ -3237,7 +3237,7 @@ mod test { let wake = Arc::new(Wake::default()); let waiter = kio::Waiter::new(wake.clone().into()); assert!(kio::Task::poll(&mut *fetching, &waiter).is_pending()); - let _request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let _request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); wake.0.store(false, std::sync::atomic::Ordering::Relaxed); let b = copy(); let mut group = b.create_group(group::Info { sequence: 0 }).unwrap(); @@ -3506,7 +3506,7 @@ mod test { arriving.abort(Error::Stream(StreamError::Old)).unwrap(); assert!(fetched.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!((request.sequence(), request.frame_start()), (0, 1)); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(1), b"b".as_ref()).unwrap(); @@ -3562,7 +3562,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(fetched.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!((request.frame_start(), request.max_delay()), (1, Some(budget))); // The new route runs a full second past the group's successor while its fetch is unanswered. @@ -4156,7 +4156,7 @@ mod test { ); let demand = copies[0].subscription().expect("surviving reader"); assert_eq!(demand.priority, 7, "{events:?}"); - assert_eq!(demand.max_delay, Duration::from_secs(2), "{events:?}"); + assert_eq!(demand.max_delay, Some(Duration::from_secs(2)), "{events:?}"); drop(groups); drop(subscriptions); drop(control); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index f5dcd68c765..510d6a9d5e9 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -3166,6 +3166,21 @@ impl Dynamic { fill::poll_requested(&self.state, &self.fills, waiter) } + /// The next fill, which a test expects to name a single group. + #[cfg(test)] + pub(crate) fn poll_requested_fill_group(&self, waiter: &kio::Waiter) -> Poll> { + self.poll_requested_fill(waiter).map_ok(|fill| { + assert_eq!(fill.end() - fill.start().group, 1, "a one-group fill"); + fill.group(fill.start().group) + }) + } + + /// [`Self::poll_requested_fill_group`], awaited. + #[cfg(all(test, feature = "async"))] + pub(crate) async fn requested_fill_group(&self) -> Result { + kio::wait(|waiter| self.poll_requested_fill_group(waiter)).await + } + /// Watch subscriber demand without keeping the track alive. pub fn demand(&self) -> Demand { Demand::new(self.name.clone(), self.state.weak()) @@ -5411,7 +5426,7 @@ impl Cursor { } let asks = { let state = self.state.read(); - misses.judge(&state, (from, revision, cap), || admitted(window, from, cap), drift, &mut given_up) + misses.judge(&state, (from, revision, cap), || admitted(window, from, cap), drift, &mut given_up, waiter) }; misses.ask(&fills, &asks, &asking, &mut given_up); loop { @@ -8733,19 +8748,19 @@ mod test { // waited for longer than the publisher keeps it. The subscriber's own preference // is stored verbatim, so what it asked for stays readable. let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); - assert_eq!(subscriber.subscription().max_delay, Duration::from_secs(10)); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(subscriber.subscription().max_delay, Some(Duration::from_secs(10))); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); // A budget within the cache is left alone, and ZERO (skip immediately) stays ZERO. subscriber .update(Subscription::default().with_max_delay(Duration::from_millis(500))) .unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_millis(500)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_millis(500))); subscriber .update(Subscription::default().with_max_delay(Duration::ZERO)) .unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::ZERO); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::ZERO)); } /// Mint a track under an origin whose retention ceiling is `cap`, so the @@ -8822,13 +8837,13 @@ mod test { // The clamp lives in the aggregation, so it applies no matter which entry point // wrote the raw preference. Previously only `Subscriber::update` clamped. let mut subscriber = producer.subscribe(over.clone()); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); subscriber.control().update(over.clone()).unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); subscriber.update(over).unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); } #[test] @@ -8840,7 +8855,7 @@ mod test { let _a = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))); let _b = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); } #[test] @@ -10322,7 +10337,7 @@ mod test { let dynamic = request.dynamic(); let consumer = request.consume(); let mut pending = consumer.fetch_group(2, None); - let Poll::Ready(Ok(req)) = dynamic.poll_requested_group(&waiter) else { + let Poll::Ready(Ok(req)) = dynamic.poll_requested_fill_group(&waiter) else { panic!("fetch request should be ready"); }; let mut fetched = req.accept(None).unwrap(); @@ -10363,7 +10378,7 @@ mod test { let pending = consumer.fetch_group(100, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("fetch request is ready") .unwrap(); @@ -11288,7 +11303,7 @@ mod test { assert_eq!(arrival.assert_group().sequence, 1); let _pending = consumer.fetch_group(0, None); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); req.accept(None).unwrap().finish().unwrap(); assert!( arrival.recv_group().now_or_never().is_none(), @@ -12570,7 +12585,7 @@ mod test { assert_eq!(&g.read_frame().await.unwrap().unwrap().payload[..], b"hello"); // Nothing was queued for the dynamic handler to serve. - assert!(dynamic.poll_requested_group(&kio::Waiter::noop()).is_pending()); + assert!(dynamic.poll_requested_fill_group(&kio::Waiter::noop()).is_pending()); } #[moq_clock::test] @@ -12588,7 +12603,7 @@ mod test { assert!(kio::Task::poll(&mut *pending, &kio::Waiter::noop()).is_pending()); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12616,14 +12631,14 @@ mod test { let pending = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); req.reject(Error::Cancel); assert!(matches!(pending.await, Err(Error::Cancel))); - let fetch = producer.state.read().fetch.clone(); + let fetch = producer.state.read().fills.clone(); assert!(fetch.read().is_empty()); } @@ -12636,7 +12651,7 @@ mod test { let pending = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12656,25 +12671,25 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); let second = consumer.fetch_group(5, None); drop(first); - assert!(req.demand().poll_unused(&kio::Waiter::noop()).is_pending()); + assert!(req.poll_unused(&kio::Waiter::noop()).is_pending()); drop(second); - assert!(req.demand().poll_unused(&kio::Waiter::noop()).is_ready()); + assert!(req.poll_unused(&kio::Waiter::noop()).is_ready()); let retry = consumer.fetch_group(5, None); let fresh = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); drop(req); - assert!(fresh.demand().poll_unused(&kio::Waiter::noop()).is_pending()); + assert!(fresh.poll_unused(&kio::Waiter::noop()).is_pending()); fresh.accept(Info::timed()).unwrap().finish().unwrap(); assert_eq!(retry.await.unwrap().sequence, 5); } @@ -12689,8 +12704,8 @@ mod test { let consumer = producer.consume(); drop(consumer.fetch_group(5, None)); - assert!(dynamic.requested_group().now_or_never().is_none()); - let fetch = producer.state.read().fetch.clone(); + assert!(dynamic.requested_fill_group().now_or_never().is_none()); + let fetch = producer.state.read().fills.clone(); assert!(fetch.read().is_empty()); } @@ -12705,7 +12720,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12716,7 +12731,7 @@ mod test { assert!(kio::Task::poll(&mut *retry, &kio::Waiter::noop()).is_pending()); let fresh = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); @@ -12735,7 +12750,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12743,7 +12758,7 @@ mod test { let retry = consumer.fetch_group(5, None); let fresh = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); @@ -12756,7 +12771,7 @@ mod test { let mut fetched = retry.await.unwrap(); assert_eq!(&fetched.read_frame().await.unwrap().unwrap().payload[..], b"hi"); - assert!(!fresh.demand().is_used(), "the retry left the fresh request"); + assert!(!fresh.is_used(), "the retry left the fresh request"); assert!(matches!(fresh.accept(Info::timed()), Err(Error::Duplicate))); } @@ -12780,7 +12795,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12808,7 +12823,7 @@ mod test { let _tail = consumer.fetch_group(5, group::Fetch::default().with_frame_start(3)); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12816,7 +12831,7 @@ mod test { let mut whole = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the wider fetch misses and queues its own request") .unwrap(); @@ -12835,7 +12850,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12845,7 +12860,7 @@ mod test { let retry = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); @@ -12862,7 +12877,7 @@ mod test { let pending = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12871,7 +12886,7 @@ mod test { let retry = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -12916,7 +12931,7 @@ mod test { "must not answer from the tail" ); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); assert_eq!((request.sequence(), request.frame_start()), (0, 0)); // Serving it replaces the too-narrow entry rather than colliding with it. @@ -12950,7 +12965,7 @@ mod test { .unwrap(); let fetch = consumer.fetch_group(0, None); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); let mut head = request.accept(Info::timed()).unwrap(); for payload in [&b"snapshot"[..], b"delta"] { head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(payload)) @@ -12994,7 +13009,7 @@ mod test { live.start_at(1).unwrap(); live.write_frame(Timestamp::ZERO, b"delta".as_slice()).unwrap(); let fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_group().await.unwrap().accept(Info::timed()).unwrap(); + let mut head = dynamic.requested_fill_group().await.unwrap().accept(Info::timed()).unwrap(); for _ in 0..3 { head.write_frame(Timestamp::ZERO, b"x".as_slice()).unwrap(); } @@ -13020,7 +13035,7 @@ mod test { .unwrap(); let fetch = consumer.fetch_group(0, None); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); let mut head = request.accept(Info::timed()).unwrap(); for payload in [&b"snapshot"[..], b"delta"] { head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(payload)) @@ -13051,7 +13066,7 @@ mod test { ); let _fetch = consumer.fetch_group(0, None); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); let mut head = request.accept(Info::timed()).unwrap(); for payload in [&b"snapshot"[..], b"delta"] { head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(payload)) @@ -13143,7 +13158,7 @@ mod test { .unwrap(); live.finish().unwrap(); let fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_group().await.unwrap().accept(Info::timed()).unwrap(); + let mut head = dynamic.requested_fill_group().await.unwrap().accept(Info::timed()).unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from(vec![0u8; 10_000])) .unwrap(); head.finish().unwrap(); @@ -13194,7 +13209,7 @@ mod test { let mut wider = std::pin::pin!(_wider); assert!(futures::poll!(wider.as_mut()).is_pending()); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); assert_eq!(request.frame_start(), 2, "widened while queued"); // Handed off now: the handler holds its own copy of the range, so a later wider @@ -13206,7 +13221,7 @@ mod test { // A handler may keep tracking the joined demand past the accept, as the lite // subscriber does, which holds the result channel open. - let _joined = request.result.clone(); + let _joined = request.joined(); let mut group = request.accept(Info::timed()).unwrap(); // A handler numbers the frames from where it was asked to start, as `serve_fetch` // does; that offset is what makes the cached group too narrow for `widest`. @@ -13248,7 +13263,7 @@ mod test { assert!(kio::Task::poll(&mut *first, &kio::Waiter::noop()).is_pending()); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -13256,7 +13271,7 @@ mod test { assert_eq!(req.priority(), 7); assert_eq!(req.max_delay(), Some(Duration::from_secs(10))); assert!( - dynamic.poll_requested_group(&kio::Waiter::noop()).is_pending(), + dynamic.poll_requested_fill_group(&kio::Waiter::noop()).is_pending(), "the second fetch queued a duplicate request" ); @@ -13285,7 +13300,7 @@ mod test { let consumer = producer.consume(); let tight = consumer.fetch_group(5, group::Fetch::default().with_max_delay(Duration::from_secs(1))); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(req.max_delay(), Some(Duration::from_secs(1))); let mut loose = consumer.fetch_group(5, None); assert!(kio::Task::poll(&mut *loose, &kio::Waiter::noop()).is_pending()); @@ -13294,7 +13309,7 @@ mod test { assert!(matches!(tight.await, Err(Error::Old))); assert!(kio::Task::poll(&mut *loose, &kio::Waiter::noop()).is_pending()); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(req.max_delay(), None); let mut group = req.accept(Info::timed()).unwrap(); group @@ -13314,7 +13329,7 @@ mod test { let first = consumer.fetch_group(5, None); let second = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -13327,7 +13342,7 @@ mod test { let mut retry = consumer.fetch_group(5, None); assert!(kio::Task::poll(&mut *retry, &kio::Waiter::noop()).is_pending()); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -13348,7 +13363,7 @@ mod test { assert!(matches!(pending.await, Err(Error::NotFound))); // And the attempt didn't leak. - let fetch = producer.state.read().fetch.clone(); + let fetch = producer.state.read().fills.clone(); assert!(fetch.read().is_empty()); } @@ -13377,7 +13392,7 @@ mod test { assert!(matches!(consumer.fetch_group(5, None).await, Err(Error::NotFound))); // And it doesn't signal the dynamic handler. - assert!(dynamic.poll_requested_group(&kio::Waiter::noop()).is_pending()); + assert!(dynamic.poll_requested_fill_group(&kio::Waiter::noop()).is_pending()); } /// Mint a track whose groups charge into a bounded [`cache::Pool`]. @@ -13608,7 +13623,7 @@ mod test { // Serve a backfill before the track is accepted, then grow it. let pending = consumer.fetch_group(0, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -13802,7 +13817,7 @@ mod test { // Serve backfill seq 0 before the track is accepted. let pending = consumer.fetch_group(0, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -13864,7 +13879,7 @@ mod test { for sequence in 1..=5u64 { let pending = consumer.fetch_group(sequence, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14004,7 +14019,7 @@ mod test { // old arrival entry for seq 1 now resolves to it. let pending = consumer.fetch_group(1, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14036,7 +14051,7 @@ mod test { for sequence in [2u64, 3u64] { let pending = consumer.fetch_group(sequence, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14100,7 +14115,7 @@ mod test { // Re-fetch it: the replacement takes over max_sequence. let pending = consumer.fetch_group(1, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14141,7 +14156,7 @@ mod test { let pending = consumer.fetch_group(0, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14207,7 +14222,7 @@ mod test { let pending = consumer.fetch_group(2, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14259,7 +14274,7 @@ mod test { // Fetch the gap: it lands in the cache and resolves the fetch... let pending = consumer.fetch_group(2, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14293,7 +14308,7 @@ mod test { // Serve a backfill fetch for an old sequence. let pending = consumer.fetch_group(2, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14503,93 +14518,101 @@ mod test { )); } + /// Every fetch that joined a fill counts as its demand, and the last to leave + /// withdraws it. #[test] - #[cfg(feature = "async")] - fn fetch_request_demand_counts_every_joined_caller() { + fn fill_demand_counts_every_joined_caller() { let producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); let consumer = producer.consume(); let first = consumer.fetch_group(3, None); let second = consumer.fetch_group(3, None); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); - let demand = request.demand(); - assert_eq!(demand.sequence(), 3); - assert!(demand.is_used()); + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("one fill for both fetches"); + }; + assert!(dynamic.poll_requested_fill(&waiter).is_pending(), "the second joined"); + assert_eq!((fill.start(), fill.end()), (Position::group(3), 4)); + assert!(fill.is_used()); drop(first); - assert!(demand.poll_unused(&kio::Waiter::noop()).is_pending()); + assert!(fill.poll_unused(&waiter).is_pending()); drop(second); - assert!(matches!(demand.poll_unused(&kio::Waiter::noop()), Poll::Ready(Ok(())))); - request.reject(Error::Cancel); - assert!(matches!(demand.used().now_or_never(), Some(Err(Error::Cancel)))); - - // The async close helper reports clean acceptance separately from rejection. - for accepted in [true, false] { - let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); - let consumer = producer.consume(); - let fetch = consumer.fetch_group(3, None); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); - let demand = request.demand(); - let group = if accepted { - Some(request.accept(Info::timed()).unwrap()) - } else { - request.reject(Error::NotFound); - None - }; - assert!(matches!( - (accepted, demand.closed().now_or_never()), - (true, Some(Error::Dropped)) | (false, Some(Error::NotFound)) - )); - drop(fetch); - if let Some(mut group) = group { - group.finish().unwrap(); - } - } + assert!(fill.poll_unused(&waiter).is_ready()); + drop(fill); + assert!( + !producer.state.read().drops.contains(&3), + "a withdrawn fill records nothing" + ); } + /// A fill accepting a group resolves the fetch from the cache. #[test] - fn accepted_group_request_demand_ends_cleanly() { + fn accepted_fill_resolves_its_fetch() { let producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); let consumer = producer.consume(); let mut fetch = consumer.fetch_group(3, None); let waiter = kio::Waiter::noop(); - let Poll::Ready(Ok(request)) = dynamic.poll_requested_group(&waiter) else { - panic!("the fetch request is ready"); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the fill is ready"); }; - let demand = request.demand(); - let mut group = request.accept(Info::timed()).unwrap(); - - assert!(!demand.is_used()); - assert!(matches!(demand.poll_used(&waiter), Poll::Ready(Err(Error::Dropped)))); - assert!(matches!(demand.poll_unused(&waiter), Poll::Ready(Err(Error::Dropped)))); + let mut group = fill.accept(3, Info::timed()).unwrap(); + assert!(matches!(fill.accept(3, None), Err(Error::Duplicate))); + assert!(matches!(fill.accept(4, None), Err(Error::InvalidRange))); assert!( matches!(kio::Task::poll(&mut *fetch, &waiter), Poll::Ready(Ok(_))), - "the fetch still gets the accepted group" + "the fetch gets the accepted group" ); group.finish().unwrap(); } - #[test] - fn rejected_group_request_demand_keeps_its_reason() { - let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); + /// Fetch group 3 and drop it through the fill it queues, returning how the fetch ended. + fn drop_through_fill(producer: &Producer, dynamic: &Dynamic, drop_all: bool) -> Error { let consumer = producer.consume(); let mut fetch = consumer.fetch_group(3, None); let waiter = kio::Waiter::noop(); - let Poll::Ready(Ok(request)) = dynamic.poll_requested_group(&waiter) else { - panic!("the fetch request is ready"); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the fill is ready"); }; - let demand = request.demand(); - request.reject(Error::NotFound); + match drop_all { + true => fill.drop_groups(0..10), + false => drop(fill), + } + match kio::Task::poll(&mut *fetch, &waiter) { + Poll::Ready(Err(err)) => err, + _ => panic!("the fetch fails"), + } + } - assert!(!demand.is_used()); - assert!(matches!(demand.poll_used(&waiter), Poll::Ready(Err(Error::NotFound)))); - assert!(matches!(demand.poll_unused(&waiter), Poll::Ready(Err(Error::NotFound)))); - assert!(matches!( - kio::Task::poll(&mut *fetch, &waiter), - Poll::Ready(Err(Error::NotFound)) - )); + fn drops(producer: &Producer) -> Vec> { + producer.state.read().drops.iter().cloned().collect() + } + + /// A group the source no longer has fails its fetch, and is named dropped for good only + /// once the live feed gave up on it too: a fetch answered before a late group header + /// arrives says nothing about the feed. + #[moq_clock::test] + async fn dropped_fill_group_fails_its_fetch() { + let producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + write_group(&producer, 5); + assert!(matches!(drop_through_fill(&producer, &dynamic, true), Error::NotFound)); + assert!(drops(&producer).is_empty(), "the feed may still deliver 3"); + + moq_clock::sim::advance(Duration::from_secs(60)).await; + assert!(matches!(drop_through_fill(&producer, &dynamic, true), Error::NotFound)); + assert_eq!(drops(&producer), vec![3..4], "clipped to the fill"); + } + + /// Dropping a fill a reader still waits on names what it left as dropped. + #[moq_clock::test] + async fn fill_dropped_unanswered_names_the_rest_dropped() { + let producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + write_group(&producer, 5); + moq_clock::sim::advance(Duration::from_secs(60)).await; + assert!(matches!(drop_through_fill(&producer, &dynamic, false), Error::Dropped)); + assert_eq!(drops(&producer), vec![3..4]); } #[moq_clock::test] @@ -14912,7 +14935,7 @@ mod test { let consumer = producer.consume(); let mut early = producer.subscribe(replay().with_ranges([Range::live()])); let _fetching = consumer.fetch_group(5, None); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); req.accept(None).unwrap().finish().unwrap(); producer .create_group(group::Info { sequence: 0 }) diff --git a/rs/moq-net/src/model/track/demand.rs b/rs/moq-net/src/model/track/demand.rs index d447fd463c2..f3e060fcb58 100644 --- a/rs/moq-net/src/model/track/demand.rs +++ b/rs/moq-net/src/model/track/demand.rs @@ -320,7 +320,7 @@ mod tests { .collect(); let next = Subscription { priority: rng.random_range(0..8), - max_delay: Duration::from_millis(rng.random_range(0..8)), + max_delay: Some(Duration::from_millis(rng.random_range(0..8))), ranges, order: if rng.random_bool(0.5) { Order::Ascending diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 0ec4fd1150c..e6fd4d8a18e 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -60,7 +60,7 @@ pub(crate) struct Outcome { impl Outcome { /// Why `sequence` was refused, if it was. - pub(super) fn refused(&self, sequence: u64) -> Option<&Error> { + pub(crate) fn refused(&self, sequence: u64) -> Option<&Error> { self.refused .iter() .rev() @@ -184,6 +184,12 @@ impl FillState { !self.queue.is_empty() } + /// Whether no fill is pending, queued or taken. + #[cfg(test)] + pub(super) fn is_empty(&self) -> bool { + self.pending.is_empty() && self.queue.is_empty() && self.covers.is_empty() + } + /// Join every fill covering `ask`, queueing a new one for each run none covers. Returns /// the joins, and the runs nobody can fill: no handler is alive to take a new fill. pub(super) fn join(&mut self, shared: &kio::Shared, ask: &Ask) -> (Vec, Vec>) { @@ -540,7 +546,7 @@ impl Inner { } if let Ok(mut state) = TrackState::modify(&self.state) { for groups in unresolved.iter() { - state.drop_groups(groups.clone()); + state.drop_unfed(groups.clone()); } } self.refuse(&unresolved, err); @@ -613,28 +619,9 @@ impl Drop for Inner { // since (the live feed delivered it) is no drop at all. let left = self.unresolved(self.start.group..self.end); if !left.is_empty() && self.result.is_used() { - let mut dropped = RangeSet::new(); if let Ok(mut state) = TrackState::modify(&self.state) { for groups in left.iter() { - let mut from = groups.start; - let cached: Vec = state - .lookup - .range(groups.clone()) - .filter(|(_, slot)| !slot.is_aborted()) - .map(|(sequence, _)| *sequence) - .collect(); - for sequence in cached { - if from < sequence { - dropped.insert(from..sequence); - } - from = sequence + 1; - } - if from < groups.end { - dropped.insert(from..groups.end); - } - } - for groups in dropped.iter() { - state.drop_groups(groups.clone()); + state.drop_unfed(groups.clone()); } } if let Ok(mut outcome) = self.result.write() { @@ -647,6 +634,27 @@ impl Drop for Inner { } } +impl TrackState { + /// Record `groups` as dropped, as a fill's source answered, except what the cache holds + /// or the live feed may still deliver: a fill refused is no verdict on the feed. + fn drop_unfed(&mut self, groups: ops::Range) { + // Until the serving session resolves where the feed starts, it may deliver anything. + if self.start_pending { + return; + } + let now = self.cache.pool().now_wall(); + let mut dropped = RangeSet::new(); + for hole in super::miss::holes(self, groups) { + for groups in self.unfed(hole, now).0.iter() { + dropped.insert(groups.clone()); + } + } + for groups in dropped.iter() { + self.drop_groups(groups.clone()); + } + } +} + /// One group of a [`Fill`], for a session serving it a group at a time. Answering it answers /// that group of the fill; dropping it unanswered leaves the group to the fill. // private: capability gap @@ -682,6 +690,12 @@ impl GroupFill { self.inner.result.poll_unused(waiter).map(|_| ()) } + /// Whether a reader still waits on the fill. + #[cfg(test)] + pub(crate) fn is_used(&self) -> bool { + self.inner.result.is_used() + } + /// The fill's readers, for a session keeping a group alive while any still wait. pub(crate) fn joined(&self) -> kio::Producer { self.inner.result.clone() diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 0f6b22cec78..7e09ef9083c 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -6,6 +6,10 @@ //! under where the feed starts, or accounted for by the arrival ledger without being cached //! (evicted, or a gap the feed skipped). A hole the ledger still waits for is deferred and //! judged again as the feed moves. +//! +//! A start is no promise, since a group below it may still arrive late. A hole under it is +//! asked at once, but a refusal is final only once the ledger gives up on it too; until +//! then the live feed may still deliver it. use std::{ collections::BTreeSet, @@ -39,6 +43,9 @@ pub(super) struct Misses { judged: RangeSet, /// Holes the live feed may still deliver, judged again on every probe. deferred: RangeSet, + /// Asked under the feed's start and refused, never asked again: given up once the feed + /// gives up on them, unless they arrive first. + refused: RangeSet, /// The fills this reader waits on. joins: Vec, /// The sequences of `joins` still unanswered. @@ -160,25 +167,27 @@ impl Misses { continue; } }; + // Read only after the outcome's lock is released. + let state = state.read(); for (groups, answer) in answers { self.wanted.remove(groups.clone()); match answer { Ok(()) => { - let state = state.read(); for sequence in groups { match state.lookup.get(&sequence).is_some_and(|slot| !slot.is_aborted()) { true => { self.filled.insert(sequence); } - false => self.give_up(sequence..sequence + 1, given_up), + false => self.settle(&state, sequence..sequence + 1, given_up), } } } Err((Error::Old, asked)) if looser(max_delay, asked) => retry.insert(groups), // A drop the track recorded is named by the drop cursor already. - Err(_) => self.give_up(groups, given_up), + Err(_) => self.settle(&state, groups, given_up), } } + drop(state); // Judged again: the same join may have more to answer, or park the waiter now. } retry @@ -193,6 +202,7 @@ impl Misses { target: impl FnOnce() -> RangeInclusiveSet, drift: &Drift, given_up: &mut GivenUp, + waiter: &kio::Waiter, ) -> RangeSet { let mut asks = RangeSet::new(); // The feed's start is unknown until the serving session resolves it, and readers @@ -218,10 +228,12 @@ impl Misses { self.at = Some(at); let low = state.live_floor.unwrap_or(0); + // A run's exclusive end must exist, so the last sequence is never judged. + let top = newest.min(u64::MAX - 1); let mut span = RangeSet::new(); - if low <= newest { - for run in target().overlapping(&(low..=newest)) { - span.insert((*run.start()).max(low)..(*run.end()).min(newest) + 1); + if low <= top { + for run in target().overlapping(&(low..=top)) { + span.insert((*run.start()).max(low)..(*run.end()).min(top) + 1); } } // What left the span is forgotten, so it is judged afresh if it comes back. @@ -241,42 +253,50 @@ impl Misses { self.judged.insert(run.clone()); } - let start = state.start_sequence.or(state.arrivals.first()); - let datagrams: BTreeSet = state.datagrams.iter().map(|datagram| datagram.sequence).collect(); + let now = state.cache.pool().now_wall(); + let start = state.start_sequence.unwrap_or(0); + let mut wake: Option = None; for run in candidates.iter() { for hole in holes(state, run.clone()) { for hole in self.unclaimed(state, hole) { - let Some(start) = start else { - self.deferred.insert(hole); - continue; - }; - // Below where the feed starts: it will never come live. - if hole.start < start { - asks.insert(hole.start..hole.end.min(start)); - } - let above = hole.start.max(start)..hole.end; - if above.is_empty() { - continue; - } - // Accounted for by the feed and not cached: evicted, or skipped. A buffered - // datagram arrived and is served as one. - let mut from = above.start; - for arrived in state.arrivals.accounted(above.clone()) { - if from < arrived.start { - self.deferred.insert(from..arrived.start); - } - asks.insert(arrived.clone()); - for sequence in datagrams.range(arrived.clone()) { - asks.remove(*sequence..*sequence + 1); + let (unfed, awaited) = state.unfed(hole, now); + for run in unfed.iter() { + for refused in self.refused.overlapping(run).cloned().collect::>() { + let gone = refused.start.max(run.start)..refused.end.min(run.end); + self.refused.remove(gone.clone()); + self.give_up(gone, given_up); } - from = arrived.end; + asks.insert(run.clone()); } - if from < above.end { - self.deferred.insert(from..above.end); + for (run, folds) in awaited { + // Under the feed's start: asked now rather than after the grace. + let early = run.start..run.end.min(start); + if !early.is_empty() { + asks.insert(early); + } + self.deferred.insert(run); + wake = match (wake, folds) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; } } } } + for run in self.refused.iter() { + asks.remove(run.clone()); + } + for run in asks.iter() { + self.deferred.remove(run.clone()); + } + // A buffered datagram arrived, and is served as one. + for datagram in &state.datagrams { + asks.remove(datagram.sequence..datagram.sequence.saturating_add(1)); + } + // A gap the feed still waits for becomes a miss when it folds. + if let Some(at) = wake { + state.cache.watch_wall(at, waiter); + } // Starts never rewind, so a run whose newest group is stale under this reader's // budget is stale throughout. @@ -334,6 +354,22 @@ impl Misses { left.iter().cloned().collect() } + /// Take in a fill's refusal of `groups`: final where the feed gave up on them too, + /// otherwise held until it does, since the live feed may still deliver them. + fn settle(&mut self, state: &TrackState, groups: ops::Range, given_up: &mut GivenUp) { + let now = state.cache.pool().now_wall(); + let mut left = RangeSet::new(); + left.insert(groups.clone()); + for run in state.unfed(groups, now).0.iter() { + left.remove(run.clone()); + self.give_up(run.clone(), given_up); + } + for run in left.iter() { + self.refused.insert(run.clone()); + self.deferred.insert(run.clone()); + } + } + fn give_up(&mut self, groups: ops::Range, given_up: &mut GivenUp) { if groups.is_empty() { return; @@ -343,8 +379,45 @@ impl Misses { } } +impl TrackState { + /// Split `hole`, which the cache holds nothing for, into what the live feed will never + /// deliver and the gaps it still waits for, with when each stops being waited for. + /// + /// Never: accounted for by its arrivals (evicted, or a gap it skipped), or in a gap its + /// grace gave up on. A declared start is no promise: a group below it may still arrive. + #[allow(clippy::type_complexity)] + pub(super) fn unfed( + &self, + hole: ops::Range, + now: crate::time::Instant, + ) -> (RangeSet, Vec<(ops::Range, Option)>) { + let mut unfed = RangeSet::new(); + let mut awaited = Vec::new(); + let mut from = hole.start; + let grace = self.arrivals.grace(); + let mut gap = |groups: ops::Range, unfed: &mut RangeSet| match self + .arrivals + .folds_at(groups.start, grace) + { + Some(at) if at <= now => unfed.insert(groups), + folds => awaited.push((groups, folds)), + }; + for arrived in self.arrivals.accounted(from..hole.end) { + if from < arrived.start { + gap(from..arrived.start, &mut unfed); + } + from = arrived.end; + unfed.insert(arrived); + } + if from < hole.end { + gap(from..hole.end, &mut unfed); + } + (unfed, awaited) + } +} + /// The runs of `run` the cache holds nothing servable for. -fn holes(state: &TrackState, run: ops::Range) -> Vec> { +pub(super) fn holes(state: &TrackState, run: ops::Range) -> Vec> { let mut holes = Vec::new(); let mut from = run.start; for (&sequence, slot) in state.lookup.range(run.clone()) { diff --git a/rs/moq-net/tests/integration/fetch_cancel.rs b/rs/moq-net/tests/integration/fetch_cancel.rs index 37cc7f447c3..a3df1220d47 100644 --- a/rs/moq-net/tests/integration/fetch_cancel.rs +++ b/rs/moq-net/tests/integration/fetch_cancel.rs @@ -67,7 +67,7 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage }; let groups = request.dynamic(); let _track = request.accept(timed()); - let request = match futures::future::select(std::pin::pin!(groups.requested_group()), &mut waiting).await { + let request = match futures::future::select(std::pin::pin!(groups.requested_fill()), &mut waiting).await { futures::future::Either::Left((request, _)) => request.expect("the fetch reaches the publisher"), futures::future::Either::Right(_) => panic!("nothing answered the fetch"), }; @@ -77,14 +77,13 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage drop(waiting); moq_clock::timeout( Duration::from_secs(5), - kio::wait(|waiter| request.demand().poll_unused(waiter)), + kio::wait(|waiter| request.poll_unused(waiter)), ) .await - .unwrap_or_else(|_| panic!("{ctx}: the publisher's fetch is still wanted")) - .expect("the pending fetch is still open"); + .unwrap_or_else(|_| panic!("{ctx}: the publisher's fetch is still wanted")); } Stage::MidResponse => { - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group.write_frame(Timestamp::ZERO, Bytes::from_static(b"head")).unwrap(); let mut fetched = waiting.await.unwrap(); @@ -112,7 +111,7 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage assert_eq!(payloads, [&b"head"[..], &b"tail"[..]], "{ctx}"); } Stage::Drained => { - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group.write_frame(Timestamp::ZERO, Bytes::from_static(b"head")).unwrap(); group.write_frame(Timestamp::ZERO, Bytes::from_static(b"tail")).unwrap(); @@ -186,11 +185,11 @@ async fn a_refetch_within_the_linger_stays_on_the_relay(version: &str) { }; let groups = request.dynamic(); let _track = request.accept(timed()); - let request = match futures::future::select(std::pin::pin!(groups.requested_group()), &mut waiting).await { + let request = match futures::future::select(std::pin::pin!(groups.requested_fill()), &mut waiting).await { futures::future::Either::Left((request, _)) => request.expect("the fetch reaches the publisher"), futures::future::Either::Right(_) => panic!("nothing answered the fetch"), }; - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group .write_frame(Timestamp::ZERO, Bytes::from_static(b"segment")) .unwrap(); @@ -206,7 +205,7 @@ async fn a_refetch_within_the_linger_stays_on_the_relay(version: &str) { let mut refetch = Box::pin(consumer.track("video").unwrap().fetch_group(0, None)); let track_request = std::pin::pin!(dynamic.requested_track()); - let group_request = std::pin::pin!(groups.requested_group()); + let group_request = std::pin::pin!(groups.requested_fill()); let upstream = futures::future::select(track_request, group_request); let mut fetched = match futures::future::select(&mut refetch, upstream).await { futures::future::Either::Left((fetched, _)) => fetched.expect("the relay serves the group again"), diff --git a/rs/moq-net/tests/integration/fetch_caps.rs b/rs/moq-net/tests/integration/fetch_caps.rs index 5cb05f87b3b..fbf26cdec4c 100644 --- a/rs/moq-net/tests/integration/fetch_caps.rs +++ b/rs/moq-net/tests/integration/fetch_caps.rs @@ -53,7 +53,7 @@ async fn an_over_cap_peer_cannot_cancel_another_peers_held_fetch() { let mut a = Box::pin(peers[0].2.track("video").unwrap().fetch_group(7, None)); let request_a = match moq_clock::timeout( TIMEOUT, - futures::future::select(std::pin::pin!(groups.requested_group()), &mut a), + futures::future::select(std::pin::pin!(groups.requested_fill()), &mut a), ) .await .unwrap() @@ -64,7 +64,7 @@ async fn an_over_cap_peer_cannot_cancel_another_peers_held_fetch() { let mut b = Box::pin(peers[1].2.track("video").unwrap().fetch_group(8, None)); let request_b = match moq_clock::timeout( TIMEOUT, - futures::future::select(std::pin::pin!(groups.requested_group()), &mut b), + futures::future::select(std::pin::pin!(groups.requested_fill()), &mut b), ) .await .unwrap() @@ -72,8 +72,8 @@ async fn an_over_cap_peer_cannot_cancel_another_peers_held_fetch() { futures::future::Either::Left((request, _)) => request.unwrap(), futures::future::Either::Right(_) => panic!("{version}: fetch answered before the publisher"), }; - assert_eq!(request_a.sequence(), 7); - assert_eq!(request_b.sequence(), 8); + assert_eq!(request_a.start().group, 7); + assert_eq!(request_b.start().group, 8); let extra = peers[0].2.track("video").unwrap().fetch_group(9, None); let (err, refused) = moq_clock::timeout(TIMEOUT, futures::future::join(peers[0].1.client.closed(), extra)) .await @@ -84,10 +84,10 @@ async fn an_over_cap_peer_cannot_cancel_another_peers_held_fetch() { "{version}: {err}" ); assert!( - request_b.demand().is_used(), + request_b.is_used(), "{version}: the other peer still holds its request" ); - let mut group = request_b.accept(info).unwrap(); + let mut group = request_b.accept(8, info).unwrap(); group.write_frame(None, &b"other peer"[..]).unwrap(); group.finish().unwrap(); let mut served = moq_clock::timeout(TIMEOUT, b).await.unwrap().unwrap(); diff --git a/rs/moq-net/tests/integration/subscriber_max_age.rs b/rs/moq-net/tests/integration/subscriber_max_age.rs index 249d6a3d5c9..1b53354830f 100644 --- a/rs/moq-net/tests/integration/subscriber_max_age.rs +++ b/rs/moq-net/tests/integration/subscriber_max_age.rs @@ -19,7 +19,7 @@ async fn budget(track: &mut track::Producer, millis: u64, version: Version) { loop { if track .subscription() - .is_some_and(|subscription| subscription.max_delay == Duration::from_millis(millis)) + .is_some_and(|subscription| subscription.max_delay == Some(Duration::from_millis(millis))) { return; } diff --git a/rs/moq-net/tests/integration/untimed.rs b/rs/moq-net/tests/integration/untimed.rs index fce1facdfa8..342df7ce100 100644 --- a/rs/moq-net/tests/integration/untimed.rs +++ b/rs/moq-net/tests/integration/untimed.rs @@ -258,11 +258,11 @@ async fn a_fetched_untimed_frame_arrives_untimed() { }; let groups = request.dynamic(); let _track = request.accept(untimed()); - let request = match futures::future::select(std::pin::pin!(groups.requested_group()), &mut waiting).await { + let request = match futures::future::select(std::pin::pin!(groups.requested_fill()), &mut waiting).await { futures::future::Either::Left((request, _)) => request.expect("the fetch reaches the publisher"), futures::future::Either::Right(_) => panic!("nothing answered the fetch"), }; - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group.write_frame(None, Bytes::from_static(b"segment")).unwrap(); group.finish().unwrap(); diff --git a/rs/moq-net/tests/loom.rs b/rs/moq-net/tests/loom.rs index 5623ac5b4f6..bdc63818b76 100644 --- a/rs/moq-net/tests/loom.rs +++ b/rs/moq-net/tests/loom.rs @@ -462,11 +462,11 @@ fn a_fetch_never_joins_a_withdrawn_attempt() { let consumer = track.consume(); let first = consumer.fetch_group(5, None); - let request = block_on(dynamic.requested_group()).expect("requested"); + let request = block_on(dynamic.requested_fill()).expect("requested"); let leaver = thread::spawn(move || { drop(first); - request.demand().is_used().then_some(request) + request.is_used().then_some(request) }); let mut retry = consumer.fetch_group(5, None); let request = match leaver.join().unwrap() { @@ -476,10 +476,10 @@ fn a_fetch_never_joins_a_withdrawn_attempt() { if let std::task::Poll::Ready(Err(err)) = polled { panic!("the retry joined a withdrawn attempt: {err}"); } - block_on(dynamic.requested_group()).expect("the retry queued a fresh request") + block_on(dynamic.requested_fill()).expect("the retry queued a fresh request") } }; - request.accept(None).expect("accept").finish().expect("finish"); + request.accept(5, None).expect("accept").finish().expect("finish"); block_on(retry).expect("the retry resolves"); }); } From 5bfd23d000b658ebc04da323d77cebddb3fba0d5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 22:39:52 -0700 Subject: [PATCH 04/48] wip: refusals below a start wait on the feed; miss tests --- rs/moq-net/src/model/track.rs | 140 ++++++++++++++++++++++++++++- rs/moq-net/src/model/track/miss.rs | 48 +++++++--- 2 files changed, 173 insertions(+), 15 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 510d6a9d5e9..50946f3cadf 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5428,13 +5428,13 @@ impl Cursor { let state = self.state.read(); misses.judge(&state, (from, revision, cap), || admitted(window, from, cap), drift, &mut given_up, waiter) }; - misses.ask(&fills, &asks, &asking, &mut given_up); + misses.ask(&self.state, &fills, &asks, &asking, &mut given_up); loop { let retry = misses.resolve(&self.state, asking.max_delay, &mut given_up, waiter); if retry.is_empty() { break; } - misses.ask(&fills, &retry, &asking, &mut given_up); + misses.ask(&self.state, &fills, &retry, &asking, &mut given_up); } if let Some(drops) = &mut self.drops { for run in given_up { @@ -14566,6 +14566,142 @@ mod test { group.finish().unwrap(); } + /// A feed declared to start at group 2, with groups 2 and 3 written, read from group 0. + fn started_late() -> (Producer, Dynamic, Subscriber) { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let subscriber = producer.subscribe(replay().with_groups(0..)); + producer.start_at(2).unwrap(); + write_group(&producer, 2); + write_group(&producer, 3); + (producer, dynamic, subscriber) + } + + fn write_filled(group: Result, millis: u64) { + let mut group = group.unwrap(); + group.write_frame(Timestamp::from_millis(millis).unwrap(), &b"x"[..]).unwrap(); + group.finish().unwrap(); + } + + fn drained_drops(subscriber: &mut Subscriber) -> Vec> { + let mut dropped = Vec::new(); + while let Poll::Ready(range) = subscriber.poll_drop(&kio::Waiter::noop()) { + dropped.push(range); + } + dropped + } + + /// What a reader asks for under the feed's start is a miss, asked at once: the reader + /// reads what the handler accepts, and what it refuses is named dropped only once the + /// feed gives up on it too, since it may still arrive late. + #[test] + #[cfg(feature = "async")] + fn a_miss_below_the_start_is_filled() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + assert!(drained_drops(&mut subscriber).is_empty()); + let mut read = Vec::new(); + while let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) { + read.push(group.sequence); + } + assert_eq!(read, [2, 3]); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 2)); + write_filled(fill.accept(0, None), 0); + fill.drop_groups(1..2); + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the filled group is read"); + }; + assert_eq!(group.sequence, 0); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(drained_drops(&mut subscriber).is_empty(), "group 1 may still arrive"); + + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [1..2]); + assert!(dynamic.poll_requested_fill(&waiter).is_pending(), "a refusal is not asked again"); + } + + /// A group the handler refused that still arrives live is delivered. + #[test] + #[cfg(feature = "async")] + fn a_refused_miss_that_arrives_late_is_read() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + assert!(drained_drops(&mut subscriber).is_empty()); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + fill.drop_groups(0..2); + write_group(&producer, 1); + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the late group is read"); + }; + assert_eq!(group.sequence, 1); + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [0..1]); + } + + /// With no handler to fill it, a miss is named dropped once the feed gives up on it. + #[test] + #[cfg(feature = "async")] + fn a_miss_with_no_handler_is_named_dropped() { + let (producer, dynamic, mut subscriber) = started_late(); + drop(dynamic); + let waiter = kio::Waiter::noop(); + assert!(drained_drops(&mut subscriber).is_empty()); + let mut read = Vec::new(); + while let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) { + read.push(group.sequence); + } + assert_eq!(read, [2, 3]); + assert!(drained_drops(&mut subscriber).is_empty()); + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [0..2]); + } + + /// An ordered reader waits on a fill for its miss rather than skipping past it. + #[test] + #[cfg(feature = "async")] + fn an_ordered_read_waits_on_a_fill() { + let (_producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + let mut next = || match subscriber.poll_next_group(&waiter) { + Poll::Ready(Ok(Some(group))) => Some(group.sequence), + Poll::Pending => None, + _ => panic!("the read ended"), + }; + assert_eq!(next(), None, "group 0 is being filled"); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + write_filled(fill.accept(0, None), 0); + assert_eq!(next(), Some(0)); + assert_eq!(next(), None, "group 1 is being filled"); + fill.drop_groups(1..2); + assert_eq!(next(), Some(2)); + assert_eq!(next(), Some(3)); + } + + /// A fill nobody waits on any more is withdrawn: it names nothing dropped, so a later + /// reader can still ask for the same history. + #[test] + #[cfg(feature = "async")] + fn a_withdrawn_fill_names_nothing_dropped() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the gap is a miss"); + }; + drop(subscriber); + assert!(!fill.is_used()); + drop(fill); + assert!(drops(&producer).is_empty()); + } + /// Fetch group 3 and drop it through the fill it queues, returning how the fetch ended. fn drop_through_fill(producer: &Producer, dynamic: &Dynamic, drop_all: bool) -> Error { let consumer = producer.consume(); diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 7e09ef9083c..e4d373d5a23 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -253,6 +253,16 @@ impl Misses { self.judged.insert(run.clone()); } + // A refused group that arrived live is the feed's, and no longer refused. + let arrived: Vec = self + .refused + .iter() + .flat_map(|run| state.lookup.range(run.clone()).map(|(sequence, _)| *sequence)) + .collect(); + for sequence in arrived { + self.refused.remove(sequence..sequence + 1); + } + let now = state.cache.pool().now_wall(); let start = state.start_sequence.unwrap_or(0); let mut wake: Option = None; @@ -261,12 +271,14 @@ impl Misses { for hole in self.unclaimed(state, hole) { let (unfed, awaited) = state.unfed(hole, now); for run in unfed.iter() { + asks.insert(run.clone()); + // Refused already: given up now the feed gave up too, not asked again. for refused in self.refused.overlapping(run).cloned().collect::>() { let gone = refused.start.max(run.start)..refused.end.min(run.end); self.refused.remove(gone.clone()); + asks.remove(gone.clone()); self.give_up(gone, given_up); } - asks.insert(run.clone()); } for (run, folds) in awaited { // Under the feed's start: asked now rather than after the grace. @@ -312,8 +324,15 @@ impl Misses { asks } - /// Ask the fills for `runs`, giving up what no handler can take. - pub fn ask(&mut self, fills: &kio::Shared, runs: &RangeSet, asking: &Asking, given_up: &mut GivenUp) { + /// Ask the fills for `runs`, settling what no handler can take as refused. + pub fn ask( + &mut self, + state: &kio::Consumer, + fills: &kio::Shared, + runs: &RangeSet, + asking: &Asking, + given_up: &mut GivenUp, + ) { for run in runs.iter() { let frame = asking .start @@ -333,7 +352,7 @@ impl Misses { self.wanted.insert(run.clone()); for groups in unfillable { self.wanted.remove(groups.clone()); - self.give_up(groups, given_up); + self.settle(&state.read(), groups, given_up); } self.joins.extend(joins); } @@ -358,15 +377,18 @@ impl Misses { /// otherwise held until it does, since the live feed may still deliver them. fn settle(&mut self, state: &TrackState, groups: ops::Range, given_up: &mut GivenUp) { let now = state.cache.pool().now_wall(); - let mut left = RangeSet::new(); - left.insert(groups.clone()); - for run in state.unfed(groups, now).0.iter() { - left.remove(run.clone()); - self.give_up(run.clone(), given_up); - } - for run in left.iter() { - self.refused.insert(run.clone()); - self.deferred.insert(run.clone()); + // What arrived meanwhile is the feed's to serve. + for hole in holes(state, groups) { + let mut left = RangeSet::new(); + left.insert(hole.clone()); + for run in state.unfed(hole, now).0.iter() { + left.remove(run.clone()); + self.give_up(run.clone(), given_up); + } + for run in left.iter() { + self.refused.insert(run.clone()); + self.deferred.insert(run.clone()); + } } } From 3b400df15b764f960ecc6eae0ea641c444b51d53 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 22:56:46 -0700 Subject: [PATCH 05/48] wip: callers and moq-ffi/moq-c move to fills --- rs/moq-archive/src/reader/mod.rs | 129 +++++++++++++++----------- rs/moq-audio/src/decode/consumer.rs | 2 +- rs/moq-c/src/api.rs | 5 +- rs/moq-c/src/publish.rs | 100 +++++++++++++++----- rs/moq-c/src/test.rs | 4 +- rs/moq-ffi/src/consumer.rs | 16 +++- rs/moq-ffi/src/demand.rs | 50 +--------- rs/moq-ffi/src/producer.rs | 114 ++++++++++++++--------- rs/moq-ffi/src/test.rs | 83 +++++------------ rs/moq-flate/src/snapshot/consumer.rs | 2 +- rs/moq-flate/src/snapshot/mod.rs | 2 +- rs/moq-flate/src/stream/consumer.rs | 2 +- rs/moq-json/src/snapshot/consumer.rs | 2 +- rs/moq-json/src/snapshot/mod.rs | 2 +- rs/moq-json/src/stream/consumer.rs | 2 +- rs/moq-mux/src/container/consumer.rs | 3 +- rs/moq-mux/src/container/source.rs | 2 +- rs/moq-net/src/model/track/fill.rs | 6 +- rs/moq-relay/tests/drills.rs | 2 +- rs/moq-transcode/src/lib.rs | 29 +++--- rs/moq-transcode/src/rung.rs | 111 ++++++++++------------ rs/moq-video/src/decode/consumer.rs | 7 +- 22 files changed, 348 insertions(+), 327 deletions(-) diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs index 8eed95d0f09..b09a16c4f73 100644 --- a/rs/moq-archive/src/reader/mod.rs +++ b/rs/moq-archive/src/reader/mod.rs @@ -38,7 +38,7 @@ use futures::TryStreamExt; use futures::future::BoxFuture; use hang::timeline::{Position, Record}; use moq_json::window; -use moq_net::{Timescale, Timestamp, broadcast, group, track}; +use moq_net::{Timescale, Timestamp, broadcast, track}; use object_store::ObjectStore; use tokio::sync::watch; @@ -98,12 +98,6 @@ struct TimelineRequests { dynamic: track::Dynamic, } -struct TimelineRequest { - name: String, - timescale: Option, - request: group::Request, -} - struct Shared { store: Store, index: Mutex, @@ -358,52 +352,65 @@ async fn serve(shared: Arc>, mut dynamic: broadcast::D /// Serve a timeline's evicted groups from their original stored objects. async fn serve_timeline(shared: Arc>, requests: TimelineRequests) { - let mut groups = kio::Tasks::new(); + let mut fills = kio::Tasks::new(); kio::wait(|waiter| { loop { - match requests.dynamic.poll_requested_group(waiter) { - Poll::Ready(Ok(request)) => { - groups.push(task(Box::pin(serve_timeline_group( - shared.clone(), - TimelineRequest { - name: requests.name.clone(), - timescale: requests.timescale, - request, - }, - )))); + match requests.dynamic.poll_requested_fill(waiter) { + Poll::Ready(Ok(fill)) => { + let (shared, name, timescale) = (shared.clone(), requests.name.clone(), requests.timescale); + fills.push(task(Box::pin(async move { + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + serve_timeline_group(&shared, &name, timescale, &fill, sequence).await; + } + }))); } Poll::Ready(Err(_)) => return Poll::Ready(()), Poll::Pending => break, } } - let _ = groups.poll(waiter); + let _ = fills.poll(waiter); Poll::Pending }) .await; } -async fn serve_timeline_group(shared: Arc>, request: TimelineRequest) { - let TimelineRequest { - name, - timescale, - request, - } = request; - let sequence = request.sequence(); +/// Where group `sequence` of `fill` starts. +fn frame_start(fill: &track::Fill, sequence: u64) -> u64 { + let start = fill.start(); + match start.group == sequence { + true => start.frame, + false => 0, + } +} + +async fn serve_timeline_group( + shared: &Shared, + name: &str, + timescale: Option, + fill: &track::Fill, + sequence: u64, +) { + let reject = || fill.drop_groups(sequence..sequence + 1); let segment = shared .timelines .lock() .unwrap() - .get(&name) + .get(name) .and_then(|groups| groups.get(&sequence)) .copied(); let Some(segment) = segment else { - request.reject(moq_net::Error::NotFound); + reject(); return; }; - let key = match Key::segments(&name, segment) { + let key = match Key::segments(name, segment) { Ok(key) => key, Err(_) => { - request.reject(moq_net::Error::NotFound); + reject(); return; } }; @@ -412,7 +419,7 @@ async fn serve_timeline_group(shared: Arc>, request: T .get_or_insert_async(&key, async { shared .store - .get_segments(&name, segment, timescale.is_some()) + .get_segments(name, segment, timescale.is_some()) .await .map(Arc::new) }) @@ -421,20 +428,20 @@ async fn serve_timeline_group(shared: Arc>, request: T Ok(object) => object, Err(err) => { tracing::warn!(track = name, sequence, %err, "archived timeline group is unavailable"); - request.reject(moq_net::Error::NotFound); + reject(); return; } }; - let start = request.frame_start(); + let start = frame_start(fill, sequence); let Some(stored) = object.groups.iter().find(|group| group.sequence == sequence) else { - request.reject(moq_net::Error::NotFound); + reject(); return; }; if object.frame_start != 0 { - request.reject(moq_net::Error::NotFound); + reject(); return; } - let Ok(mut producer) = request.accept(None) else { + let Ok(mut producer) = fill.accept(sequence, None) else { return; }; if producer.start_at(start).is_err() { @@ -452,7 +459,7 @@ async fn serve_timeline_group(shared: Arc>, request: T let _ = producer.finish(); } -/// Accept a track a timeline indexes, then serve its group requests until nobody uses it. +/// Accept a track a timeline indexes, then serve its fills until nobody uses it. async fn serve_track(shared: Arc>, request: track::Request, known: bool) { let name = request.name().to_string(); if !known { @@ -480,20 +487,30 @@ async fn serve_track(shared: Arc>, request: track::Req let dynamic = request.dynamic(); let mut demand = dynamic.demand(); let _producer = request.accept(info); - let mut groups = kio::Tasks::new(); + let mut fills = kio::Tasks::new(); kio::wait(|waiter| { loop { - match dynamic.poll_requested_group(waiter) { - Poll::Ready(Ok(request)) => { - let serve = serve_group(shared.clone(), name.clone(), timescale, request); - groups.push(task(Box::pin(serve))); + match dynamic.poll_requested_fill(waiter) { + Poll::Ready(Ok(fill)) => { + let (shared, name) = (shared.clone(), name.clone()); + fills.push(task(Box::pin(async move { + // One group at a time: a fill's groups precede its readers' live edge. + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + serve_group(&shared, &name, timescale, &fill, sequence).await; + } + }))); } Poll::Ready(Err(_)) => return Poll::Ready(()), Poll::Pending => break, } } - if groups.poll(waiter).is_pending() { + if fills.poll(waiter).is_pending() { return Poll::Pending; } // Drain the changes so the waiter stays registered, then read where demand settled. @@ -506,41 +523,41 @@ async fn serve_track(shared: Arc>, request: track::Req .await } -/// Answer one group request from the records holding it, or reject it as never delivered. +/// Answer one group of a fill from the records holding it, or drop it as never delivered. /// /// A group whose records stop short of its end keeps its producer open and grows as later records /// commit, until one reaches the group's end or the caller finishes the reader. async fn serve_group( - shared: Arc>, - track: String, + shared: &Shared, + track: &str, timescale: Option, - request: group::Request, + fill: &track::Fill, + sequence: u64, ) { - let sequence = request.sequence(); - let start = request.frame_start(); + let start = frame_start(fill, sequence); let mut changed = shared.changed.subscribe(); - let mut spans = shared.index.lock().unwrap().group(&track, sequence); + let mut spans = shared.index.lock().unwrap().group(track, sequence); // The earliest retained record must hold the requested frame; an expired head is gone. if spans .first() .is_none_or(|span| span.start > Position::new(sequence, start)) { - request.reject(moq_net::Error::NotFound); + fill.drop_groups(sequence..sequence + 1); return; } // Validate the first object before accepting, so an unreadable group is a plain miss. Keep it, // so a cache too small to hold it costs no second GET. let timed = timescale.is_some(); - let mut first = match object(&shared, &track, timed, &spans[0]).await { + let mut first = match object(shared, track, timed, &spans[0]).await { Ok(object) => Some((spans[0].sequence, object)), Err(err) => { tracing::warn!(track, sequence, %err, "archived group is unavailable"); - request.reject(moq_net::Error::NotFound); + fill.drop_groups(sequence..sequence + 1); return; } }; - let Ok(mut producer) = request.accept(None) else { + let Ok(mut producer) = fill.accept(sequence, None) else { return; }; if producer.start_at(start).is_err() { @@ -555,7 +572,7 @@ async fn serve_group( } let loaded = match first.take_if(|(loaded, _)| *loaded == span.sequence) { Some((_, object)) => Ok(object), - None => object(&shared, &track, timed, span).await, + None => object(shared, track, timed, span).await, }; let object = match loaded { Ok(object) if span.start <= Position::new(sequence, next) => object, @@ -612,7 +629,7 @@ async fn serve_group( } } changed.mark_unchanged(); - spans = shared.index.lock().unwrap().group(&track, sequence); + spans = shared.index.lock().unwrap().group(track, sequence); } } diff --git a/rs/moq-audio/src/decode/consumer.rs b/rs/moq-audio/src/decode/consumer.rs index a77ad17ebe7..9bf818afdeb 100644 --- a/rs/moq-audio/src/decode/consumer.rs +++ b/rs/moq-audio/src/decode/consumer.rs @@ -2126,7 +2126,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(observed.subscription().unwrap().max_delay, max_delay); + assert_eq!(observed.subscription().unwrap().max_delay, Some(max_delay)); let samples = [0.25f32, -0.5, 0.75, -1.0]; let payload: Vec = samples.iter().flat_map(|sample| sample.to_le_bytes()).collect(); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 74c55fa2952..01002e85b2b 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -3061,9 +3061,10 @@ pub extern "C" fn moq_group_request_accept(request: u32) -> i32 { }) } -/// Reject a group request with an application error code, failing the waiting fetches. +/// Reject a group request, failing the waiting fetches as not found. /// -/// Consumes the request handle. Returns a zero on success, or a negative code on failure. +/// The error code is not delivered. Consumes the request handle. Returns a zero on success, +/// or a negative code on failure. #[unsafe(no_mangle)] pub extern "C" fn moq_group_request_abort(request: u32, error_code: u16) -> i32 { ffi::enter(move || { diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index 6ed31705677..dd43ec7c5b8 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::sync::Arc; use moq_mux::catalog::hang::Extra; use moq_mux::import; @@ -22,10 +23,30 @@ struct TrackRequest { } /// A request a handler pulled, before it has a handle. A track request is boxed: it is -/// several times the size of a group request. +/// several times the size of a fill. enum Request { Track(Box), - Group(moq_net::group::Request), + Fill(moq_net::track::Fill), +} + +/// One group of a fill, handed to C as a group request: the frozen ABI answers fills a +/// group at a time. +struct GroupRequest { + fill: Arc, + sequence: u64, + /// Dropped once the request is answered or freed, so its fill's next group follows. + _pending: oneshot::Sender<()>, +} + +impl GroupRequest { + /// Where the group starts: the fill's start frame for its first group. + fn frame_start(&self) -> u64 { + let start = self.fill.start(); + match start.group == self.sequence { + true => start.frame, + false => 0, + } + } } /// What a request handler serves. @@ -105,7 +126,7 @@ pub struct Publish { track_request: NonZeroSlab, /// Group requests delivered to a handler, freed on accept, abort, or free. - group_request: NonZeroSlab, + group_request: NonZeroSlab, } impl Publish { @@ -589,17 +610,22 @@ impl Publish { mut dynamic: Dynamic, mut close: oneshot::Receiver<()>, ) -> Result<(), Error> { + // Each fill hands out its groups one at a time, alongside the other fills. + let mut fills = kio::Tasks::new(); loop { // The handler is owned here, so returning drops it and rejects whatever is pending. // `biased` so a pending close always wins over a ready request. let res = tokio::select! { biased; _ = &mut close => return Ok(()), - res = kio::wait(|waiter| match &mut dynamic { - Dynamic::Broadcast(dynamic, broadcast) => dynamic - .poll_requested_track(waiter) - .map_ok(|request| Request::Track(Box::new(TrackRequest { broadcast: *broadcast, request }))), - Dynamic::Track(dynamic) => dynamic.poll_requested_group(waiter).map_ok(Request::Group), + res = kio::wait(|waiter| { + let _ = fills.poll(waiter); + match &mut dynamic { + Dynamic::Broadcast(dynamic, broadcast) => dynamic + .poll_requested_track(waiter) + .map_ok(|request| Request::Track(Box::new(TrackRequest { broadcast: *broadcast, request }))), + Dynamic::Track(dynamic) => dynamic.poll_requested_fill(waiter).map_ok(Request::Fill), + } }) => res, }; @@ -611,13 +637,42 @@ impl Publish { }; // Hold the lock only to buffer the request; release it before the callback. - let mut state = State::lock(); let id = match request { - Request::Track(request) => state.publish.track_request.insert(*request)?, - Request::Group(request) => state.publish.group_request.insert(request)?, + Request::Track(request) => State::lock().publish.track_request.insert(*request)?, + Request::Fill(fill) => { + let mut serve = Box::pin(Self::serve_fill(callback, Arc::new(fill))); + fills.push(move |waiter: &kio::Waiter| waiter.poll_future(serve.as_mut())); + continue; + } + }; + callback.call(id); + } + } + + /// Hand `fill` to C a group at a time, each once the one before it is answered, until + /// every group is answered or nobody waits on it. + async fn serve_fill(callback: OnStatus, fill: Arc) { + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + let (pending, answered) = oneshot::channel(); + let request = GroupRequest { + fill: fill.clone(), + sequence, + _pending: pending, + }; + let Ok(id) = State::lock().publish.group_request.insert(request) else { + return; }; - drop(state); callback.call(id); + let unused = kio::wait(|waiter| fill.poll_unused(waiter)); + tokio::select! { + _ = answered => {} + _ = unused => return, + } } } @@ -675,7 +730,7 @@ impl Publish { /// The sequence, priority, and first frame of a requested group. pub fn group_request_info(&self, request: Id) -> Result<(u64, u8, u64), Error> { let request = self.group_request.get(request).ok_or(Error::NotFound)?; - Ok((request.sequence(), request.priority(), request.frame_start())) + Ok((request.sequence, request.fill.priority(), request.frame_start())) } /// Accept a group request, returning a group handle like [`Self::track_group`]. @@ -684,25 +739,20 @@ impl Publish { /// indices they have in the group rather than restarting at 0. pub fn group_request_accept(&mut self, request: Id) -> Result { let request = self.group_request.remove(request).ok_or(Error::NotFound)?; - let frame_start = request.frame_start(); - let mut group = request.accept(None)?; - if let Err(err) = group.start_at(frame_start) { - let _ = group.abort(err.clone()); - return Err(err.into()); - } + let group = request.fill.accept(request.sequence, None)?; self.groups.insert(group) } - /// Reject a group request, failing every fetch waiting on it with `error_code`. - pub fn group_request_abort(&mut self, request: Id, error_code: u16) -> Result<(), Error> { - let request = self.group_request.remove(request).ok_or(Error::NotFound)?; - request.reject(moq_net::Error::App(error_code)); - Ok(()) + /// Reject a group request, failing every fetch waiting on it. A fill has no error code + /// to carry, so `error_code` is not delivered: the fetches fail as not found. + pub fn group_request_abort(&mut self, request: Id, _error_code: u16) -> Result<(), Error> { + self.group_request_free(request) } /// Drop a group request, which rejects it. pub fn group_request_free(&mut self, request: Id) -> Result<(), Error> { - self.group_request.remove(request).ok_or(Error::NotFound)?; + let request = self.group_request.remove(request).ok_or(Error::NotFound)?; + request.fill.drop_groups(request.sequence..request.sequence + 1); Ok(()) } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index b270deb4f76..ac1e33954d3 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3138,7 +3138,7 @@ fn track_dynamic_serves_a_fetch_miss() { assert_eq!(moq_publish_group_finish(group), 0); assert_eq!(fetch.recv_timeout(TIMEOUT).unwrap().unwrap(), (0, payload.to_vec())); - // A rejected fetch fails with the application code. + // A rejected fetch fails as not found. let fetch = fetch_group(broadcast, "data", 6, 0, 0); let request = id(group_cb.recv()); assert_eq!(moq_group_request_abort(request, 9), 0); @@ -3146,7 +3146,7 @@ fn track_dynamic_serves_a_fetch_miss() { .recv_timeout(TIMEOUT) .unwrap() .expect_err("a rejected fetch fails"); - assert!(matches!(err, moq_net::Error::App(9)), "got {err:?}"); + assert!(matches!(err, moq_net::Error::NotFound), "got {err:?}"); assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 40be2cadba4..1fd646fdd62 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -29,12 +29,13 @@ pub struct MoqSubscription { #[uniffi(default = 0)] pub priority: u8, /// Maximum age of a non-latest group before it is skipped, in microseconds. - /// `0` skips immediately; a larger value tolerates that much reordering. + /// `0` skips immediately; a larger value tolerates that much reordering; null leaves + /// the publisher's max age alone to bound it. /// /// Enforced both by the publisher's cache (sent on the wire) and by any local /// buffering, such as [`MoqMediaTrackConsumer`]'s jitter buffer. It caps every range. #[uniffi(default = 0)] - pub max_delay_us: u64, + pub max_delay_us: Option, /// The spans of the track to deliver, or null for everything `max_delay_us` still /// considers fresh. An empty list asks for nothing. /// @@ -116,6 +117,15 @@ impl From for moq_net::track::Order { } } +impl From for MoqOrder { + fn from(order: moq_net::track::Order) -> Self { + match order { + moq_net::track::Order::Ascending => Self::Ascending, + moq_net::track::Order::Descending => Self::Descending, + } + } +} + /// Options for fetching one past group by sequence. #[derive(Clone, uniffi::Record)] pub struct MoqFetchGroupOptions { @@ -143,7 +153,7 @@ impl TryFrom for moq_net::track::Subscription { fn try_from(s: MoqSubscription) -> Result { let mut subscription = moq_net::track::Subscription::default() .with_priority(s.priority) - .with_max_delay(std::time::Duration::from_micros(s.max_delay_us)); + .with_max_delay(s.max_delay_us.map(std::time::Duration::from_micros)); if let Some(ranges) = s.ranges { subscription = subscription.with_ranges( ranges diff --git a/rs/moq-ffi/src/demand.rs b/rs/moq-ffi/src/demand.rs index f06fa74d663..c5808271a23 100644 --- a/rs/moq-ffi/src/demand.rs +++ b/rs/moq-ffi/src/demand.rs @@ -1,4 +1,4 @@ -//! Watching what subscribers want of a published track, or whether a requested group is still wanted. +//! Watching what subscribers want of a published track. use std::sync::Arc; @@ -63,52 +63,10 @@ impl MoqTrackDemand { } } -/// A watch-only handle to the callers waiting on a requested group. +/// Report a track released without an abort as [`MoqError::Closed`]. /// -/// Weak: holding it does not keep the request alive. The last caller to leave withdraws the -/// request, and a later fetch of the group queues a fresh one, so once unused, demand never -/// returns: drop the request. Waits fail with `Closed` after the request is accepted or dropped, -/// and keep the rejection reason after it is rejected. -#[derive(uniffi::Object)] -pub struct MoqGroupDemand { - inner: moq_net::group::Demand, -} - -impl MoqGroupDemand { - pub(crate) fn new(inner: moq_net::group::Demand) -> Arc { - Arc::new(Self { inner }) - } -} - -#[uniffi::export] -impl MoqGroupDemand { - /// The sequence of the group this watches. - pub fn sequence(&self) -> u64 { - self.inner.sequence() - } - - /// Whether the group has at least one waiting caller right now, without waiting. - pub fn is_used(&self) -> bool { - self.inner.is_used() - } - - /// Wait until the group has at least one waiting caller. - pub async fn used(&self) -> Result<(), MoqError> { - let demand = self.inner.clone(); - crate::ffi::detached(async move { gone(demand.used().await) }).await - } - - /// Wait until the group has no waiting callers. - pub async fn unused(&self) -> Result<(), MoqError> { - let demand = self.inner.clone(); - crate::ffi::detached(async move { gone(demand.unused().await) }).await - } -} - -/// Report a track or group released without an abort as [`MoqError::Closed`]. -/// -/// That is how a finished track or request ends, which a demand watcher expects, not the internal -/// failure `Dropped` means to a consumer. An aborted track or rejected request keeps its reason. +/// That is how a finished track ends, which a demand watcher expects, not the internal failure +/// `Dropped` means to a consumer. An aborted track keeps its reason. fn gone(result: Result) -> Result { match result { Err(moq_net::Error::Dropped) => Err(MoqError::Closed), diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 5c032c16dce..6bd8c9a164f 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use moq_mux::catalog::hang::Extra; -use crate::consumer::{MoqBroadcastConsumer, MoqGroupConsumer, MoqSubscription, MoqTrackConsumer}; -use crate::demand::{MoqGroupDemand, MoqTrackDemand}; +use crate::consumer::{MoqBroadcastConsumer, MoqGroupConsumer, MoqOrder, MoqSubscription, MoqTrackConsumer}; +use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::{Background, Task}; use crate::media::MoqFrame; @@ -134,9 +134,9 @@ impl DynamicProducer { } impl TrackDynamicProducer { - async fn requested_group(&mut self) -> Result, MoqError> { - let request = self.inner.requested_group().await?; - Ok(Arc::new(MoqGroupRequest::new(request))) + async fn requested_fill(&mut self) -> Result, MoqError> { + let fill = self.inner.requested_fill().await?; + Ok(Arc::new(MoqFill { inner: Arc::new(fill) })) } } @@ -319,17 +319,17 @@ impl MoqBroadcastDynamic { #[uniffi::export] impl MoqTrackDynamic { - /// Wait for the next fetch of an uncached group. + /// Wait for the next run of groups readers miss: a fetch of an uncached group, or + /// history a subscription reads that the cache no longer holds. /// - /// Accept the returned request to produce the group, or abort it with an - /// application error. Cached groups are served without reaching this method. - pub async fn requested_group(&self) -> Result, MoqError> { + /// Cached groups are served without reaching this method. + pub async fn requested_fill(&self) -> Result, MoqError> { self.task - .run(|mut state| async move { state.requested_group().await }) + .run(|mut state| async move { state.requested_fill().await }) .await } - /// Cancel all current and future `requested_group()` calls. + /// Cancel all current and future `requested_fill()` calls. /// /// Terminal: the dynamic track is released here, not when the handle is, so any pending /// fetch is rejected. @@ -338,61 +338,87 @@ impl MoqTrackDynamic { } } -/// An uncached group requested by a fetch consumer. +/// A run of groups readers miss, mirroring [`moq_net::track::Fill`]. +/// +/// Call [`accept`](Self::accept) for each group you have and [`drop_groups`](Self::drop_groups) +/// for the rest; [`next`](Self::next) names what is still owed, in the fill's order. Releasing +/// it names whatever is left as dropped while a reader still wants it; once nobody does, it is +/// withdrawn, so stop serving it when [`unused`](Self::unused) resolves. #[derive(uniffi::Object)] -pub struct MoqGroupRequest { - sequence: u64, - priority: u8, - inner: std::sync::Mutex>, +pub struct MoqFill { + inner: Arc, } -impl MoqGroupRequest { - fn new(request: moq_net::group::Request) -> Self { - Self { - sequence: request.sequence(), - priority: request.priority(), - inner: std::sync::Mutex::new(Some(request)), - } +#[uniffi::export] +impl MoqFill { + /// The first group to fill. + pub fn start_group(&self) -> u64 { + self.inner.start().group } - fn take(&self) -> Result { - self.inner.lock().unwrap().take().ok_or(MoqError::Closed) + /// The frame within the first group to start from. + pub fn start_frame(&self) -> u64 { + self.inner.start().frame } -} -#[uniffi::export] -impl MoqGroupRequest { - /// The requested group sequence within the track. - pub fn sequence(&self) -> u64 { - self.sequence + /// The exclusive end group. + pub fn end_group(&self) -> u64 { + self.inner.end() } - /// The consumer's delivery priority for this fetch. + /// The most urgent delivery priority among the readers waiting on it. pub fn priority(&self) -> u8 { - self.priority + self.inner.priority() } - /// A handle that watches whether any caller still wants this group. - pub fn demand(&self) -> Result, MoqError> { - let guard = self.inner.lock().unwrap(); - Ok(MoqGroupDemand::new(guard.as_ref().ok_or(MoqError::Closed)?.demand())) + /// The most tolerant age budget among the readers waiting on it, in microseconds, or null + /// for the publisher's max age alone. + pub fn max_delay_us(&self) -> Option { + self.inner + .max_delay() + .map(|max_delay| u64::try_from(max_delay.as_micros()).unwrap_or(u64::MAX)) } - /// Accept the request and return a producer for filling the fetched group. - pub fn accept(&self) -> Result, MoqError> { + /// Which end to fill first. + pub fn order(&self) -> MoqOrder { + self.inner.order().into() + } + + /// The next group still owed in the fill's order, or null once every group is answered. + pub fn next(&self) -> Option { + self.inner.next() + } + + /// Insert group `sequence` and return a producer for filling it. The first group starts + /// at [`start_frame`](Self::start_frame), so its frames keep their indices. + pub fn accept(&self, sequence: u64) -> Result, MoqError> { let _guard = crate::ffi::enter(); - let group = self.take()?.accept(None)?; + let group = self.inner.accept(sequence, None)?; Ok(Arc::new(MoqGroupProducer { sequence: group.sequence, inner: std::sync::Mutex::new(Some(group)), })) } - /// Reject the fetch with an application error code. - pub fn abort(&self, error_code: u16) -> Result<(), MoqError> { + /// Name groups `start` up to the exclusive `end` as never to be delivered. + pub fn drop_groups(&self, start: u64, end: u64) { let _guard = crate::ffi::enter(); - self.take()?.reject(moq_net::Error::App(error_code)); - Ok(()) + self.inner.drop_groups(start..end); + } + + /// Whether a reader still waits on this fill. + pub fn is_used(&self) -> bool { + self.inner.is_used() + } + + /// Wait until no reader waits on this fill. + pub async fn unused(&self) -> Result<(), MoqError> { + let fill = self.inner.clone(); + crate::ffi::detached(async move { + fill.unused().await; + Ok::<_, MoqError>(()) + }) + .await } } diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index ed8ccdd37e9..0931a3f7f68 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -918,7 +918,7 @@ async fn raw_track_update_does_not_wait_for_pending_read() { consumer .update(MoqSubscription { priority: 10, - max_delay_us: 25_000, + max_delay_us: Some(25_000), ranges: Some(vec![MoqRange { start: MoqAnchor::Absolute { group: 0, frame: 0 }, end: None, @@ -1990,14 +1990,15 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { .await }); - let request = tokio::time::timeout(TIMEOUT, dynamic.requested_group()) + let request = tokio::time::timeout(TIMEOUT, dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - assert_eq!(request.sequence(), 5); + assert_eq!((request.start_group(), request.start_frame(), request.end_group()), (5, 0, 6)); assert_eq!(request.priority(), 11); + assert_eq!(request.next(), Some(5)); - let group = request.accept().unwrap(); + let group = request.accept(5).unwrap(); group .write_frame(MoqFrame { payload: b"fetched".to_vec(), @@ -2018,52 +2019,25 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { } #[tokio::test] -async fn group_request_demand_ends_when_the_fetcher_leaves() { +async fn fill_goes_unused_when_the_fetcher_leaves() { let broadcast = MoqBroadcastProducer::new().unwrap(); let track = broadcast.publish_track("events".into(), None).unwrap(); let dynamic = track.dynamic().unwrap(); let consumer = broadcast.consume().unwrap(); let fetch = tokio::spawn(async move { consumer.fetch_group("events".into(), 5, None).await }); - let request = tokio::time::timeout(TIMEOUT, dynamic.requested_group()) + let fill = tokio::time::timeout(TIMEOUT, dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - let demand = request.demand().unwrap(); - assert_eq!(demand.sequence(), 5); - assert!(demand.is_used()); + assert!(fill.is_used()); fetch.abort(); - tokio::time::timeout(TIMEOUT, demand.unused()) + tokio::time::timeout(TIMEOUT, fill.unused()) .await .expect("timed out waiting for the abandoned fetch to become unused") .unwrap(); - assert!(!demand.is_used()); - - request.abort(404).unwrap(); - assert!(matches!(request.demand(), Err(MoqError::Closed))); -} - -/// An accepted request's demand closes cleanly while its fetch gets the group. -#[tokio::test] -async fn group_request_demand_closes_after_accept() { - tokio::time::pause(); - let broadcast = MoqBroadcastProducer::new().unwrap(); - let track = broadcast.publish_track("events".into(), None).unwrap(); - let dynamic = track.dynamic().unwrap(); - let consumer = broadcast.consume().unwrap(); - - let fetch = tokio::spawn(async move { consumer.fetch_group("events".into(), 5, None).await }); - let request = dynamic.requested_group().await.unwrap(); - let demand = request.demand().unwrap(); - let group = request.accept().unwrap(); - group.finish().unwrap(); - - for result in [demand.used().await, demand.unused().await] { - assert!(matches!(result, Err(MoqError::Closed)), "got {result:?}"); - } - assert!(!demand.is_used()); - fetch.await.expect("fetch task panicked").unwrap(); + assert!(!fill.is_used()); } #[tokio::test] @@ -2074,26 +2048,19 @@ async fn dynamic_track_rejects_fetch_miss() { let consumer = broadcast.consume().unwrap(); let fetch = tokio::spawn(async move { consumer.fetch_group("events".into(), 5, None).await }); - let request = tokio::time::timeout(TIMEOUT, dynamic.requested_group()) + let fill = tokio::time::timeout(TIMEOUT, dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - request.abort(404).unwrap(); + fill.drop_groups(5, 6); + assert_eq!(fill.next(), None, "the dropped group is answered"); let result = tokio::time::timeout(TIMEOUT, fetch) .await .expect("timed out waiting for rejected fetch") .expect("fetch task panicked"); - match result { - Err(MoqError::Protocol { details: protocol }) => { - assert_eq!(protocol.scope, crate::error::MoqErrorScope::Stream); - assert_eq!(protocol.code, 64 + 404); - assert_eq!(protocol.kind, crate::error::MoqProtocolKind::App); - } - Err(other) => panic!("expected Protocol App(404), got {other:?}"), - Ok(_) => panic!("expected Protocol App(404), got a group"), - } - assert!(matches!(request.accept(), Err(MoqError::Closed))); + assert!(matches!(result, Err(MoqError::NotFound)), "got {:?}", result.err()); + assert!(fill.accept(5).is_err(), "a dropped group is answered already"); } #[tokio::test] @@ -2129,12 +2096,12 @@ async fn requested_track_dynamic_survives_accept() { let track_dynamic = request.dynamic().unwrap(); let _track = request.accept(Some(timed_info())).unwrap(); - let group_request = tokio::time::timeout(TIMEOUT, track_dynamic.requested_group()) + let fill = tokio::time::timeout(TIMEOUT, track_dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - assert_eq!(group_request.sequence(), 9); - let group = group_request.accept().unwrap(); + assert_eq!(fill.start_group(), 9); + let group = fill.accept(9).unwrap(); group .write_frame(MoqFrame { payload: b"archive".to_vec(), @@ -3941,7 +3908,7 @@ async fn raw_track_update_during_pending_group_still_reads_datagram() { consumer .update(MoqSubscription { priority: 10, - max_delay_us: 25_000, + max_delay_us: Some(25_000), ranges: Some(vec![MoqRange { start: MoqAnchor::Absolute { group: 0, frame: 0 }, end: None, @@ -6742,7 +6709,7 @@ async fn ascending_subscription_reads_a_cached_backlog_oldest_first() { let subscription = MoqSubscription { priority: 0, - max_delay_us: 10_000_000, + max_delay_us: Some(10_000_000), ranges: Some(vec![MoqRange { start: MoqAnchor::Relative { groups: 2 }, end: None, @@ -6790,7 +6757,7 @@ async fn time_anchors_start_at_the_containing_group() { let since = |timestamp_us| MoqSubscription { priority: 0, - max_delay_us: 10_000_000, + max_delay_us: Some(10_000_000), ranges: Some(vec![MoqRange { start: MoqAnchor::Time { timestamp_us }, end: None, diff --git a/rs/moq-flate/src/snapshot/consumer.rs b/rs/moq-flate/src/snapshot/consumer.rs index 0fea756618d..47f37af75c5 100644 --- a/rs/moq-flate/src/snapshot/consumer.rs +++ b/rs/moq-flate/src/snapshot/consumer.rs @@ -139,7 +139,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let config = crate::snapshot::Config { compression }; let mut producer = crate::snapshot::Producer::new(track, config.clone()); diff --git a/rs/moq-flate/src/snapshot/mod.rs b/rs/moq-flate/src/snapshot/mod.rs index 91887e02d3c..a8e5d2de83c 100644 --- a/rs/moq-flate/src/snapshot/mod.rs +++ b/rs/moq-flate/src/snapshot/mod.rs @@ -124,7 +124,7 @@ mod 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; + subscription.max_delay = None; track.update(subscription).unwrap(); for value in [b"one".as_slice(), b"two", b"three"] { producer.update(value).unwrap(); diff --git a/rs/moq-flate/src/stream/consumer.rs b/rs/moq-flate/src/stream/consumer.rs index e52d48e678e..a555b242e54 100644 --- a/rs/moq-flate/src/stream/consumer.rs +++ b/rs/moq-flate/src/stream/consumer.rs @@ -125,7 +125,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let config = crate::stream::Config { compression }; let mut producer = crate::stream::Producer::new(track, config.clone()); diff --git a/rs/moq-json/src/snapshot/consumer.rs b/rs/moq-json/src/snapshot/consumer.rs index e630536ce56..d6ef6215989 100644 --- a/rs/moq-json/src/snapshot/consumer.rs +++ b/rs/moq-json/src/snapshot/consumer.rs @@ -173,7 +173,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let mut config = crate::snapshot::Config::default().with_delta_ratio(100); config.compression = compression; diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index 6bc03283839..12128d30a5d 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -213,7 +213,7 @@ mod 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; + subscription.max_delay = None; track.update(subscription).unwrap(); for n in [1, 2, 3] { producer diff --git a/rs/moq-json/src/stream/consumer.rs b/rs/moq-json/src/stream/consumer.rs index 12c0a548924..be0adcf89b5 100644 --- a/rs/moq-json/src/stream/consumer.rs +++ b/rs/moq-json/src/stream/consumer.rs @@ -120,7 +120,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let config = crate::stream::Config { compression }; let mut producer = crate::stream::Producer::new(track, config.clone()); diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 150766f8345..758e45760c0 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -178,6 +178,7 @@ impl Consumer { let start = track.floor(); let max_delay = subscription .max_delay + .unwrap_or(std::time::Duration::MAX) .min(track.info().max_age.unwrap_or(std::time::Duration::MAX)); Self { name: track.name().into(), @@ -1108,7 +1109,7 @@ mod tests { let mut consumer = Consumer::new(subscriber, Container::Legacy(crate::container::Kind::Data)); assert_eq!(consumer.max_delay, max_delay); - assert_eq!(consumer.subscriber().subscription().max_delay, max_delay); + assert_eq!(consumer.subscriber().subscription().max_delay, Some(max_delay)); } #[tokio::test] diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index 7a77072a90b..77e4a771f45 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -684,7 +684,7 @@ mod tests { }) .await; - assert_eq!(observed, max_delay, "live start: {live_start}"); + assert_eq!(observed, Some(max_delay), "live start: {live_start}"); } } diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index e6fd4d8a18e..bd5bcf6d12f 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -457,9 +457,9 @@ impl Fill { self.inner.result.poll_unused(waiter).map(|_| ()) } - /// The next group to fill in the fill's order, skipping what was answered. - // private: capability gap - pub(crate) fn next(&self) -> Option { + /// The next group still owed in the fill's order: neither answered nor delivered live + /// since it was asked. `None` once every group is answered. + pub fn next(&self) -> Option { loop { let resolved = self.inner.result.read().resolved(); let span = self.inner.start.group..self.inner.end; diff --git a/rs/moq-relay/tests/drills.rs b/rs/moq-relay/tests/drills.rs index 69ac35ea3d2..151eebd1a15 100644 --- a/rs/moq-relay/tests/drills.rs +++ b/rs/moq-relay/tests/drills.rs @@ -1442,7 +1442,7 @@ async fn ietf_subscriber_max_age_skips_bufferbloat() { loop { if track .subscription() - .is_some_and(|subscription| subscription.max_delay == Duration::from_millis(100)) + .is_some_and(|subscription| subscription.max_delay == Some(Duration::from_millis(100))) { break; } diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index ece4aa834b3..f29867df956 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -1318,7 +1318,7 @@ mod tests { /// source, so the source publishes no group at all and serves this one /// through a [`moq_net::track::Dynamic`] instead. That handle also parks the /// fetch at exactly the point in question: past the rung's handler, before - /// its `GroupRequest::accept`. + /// its `Fill::accept`. #[tokio::test] async fn retirement_waits_for_an_unclaimed_fetch() { let mut source = source_catalog(320, 240); @@ -1365,9 +1365,9 @@ mod tests { let fetching = rung.fetch_group(7, None); // The rung's fetch task is now inside its source read, which is upstream of - // both its decoder and the `GroupRequest::accept` that claims output group 7. - let request = source_fetches.requested_group().await.expect("the source track closed"); - assert_eq!(request.sequence(), 7); + // both its decoder and the `Fill::accept` that claims output group 7. + let request = source_fetches.requested_fill().await.expect("the source track closed"); + assert_eq!(request.start().group, 7); // Retire the rung with the fetch parked there, and let the retirement land // before the source group exists. @@ -1383,7 +1383,7 @@ mod tests { // Release the fetch: it opens its decoder and only now claims output group // 7, which retirement had to leave writable. - let mut group = request.accept(None).unwrap(); + let mut group = request.accept(7, None).unwrap(); write_keyframe(&mut group); group.finish().unwrap(); @@ -1411,8 +1411,8 @@ mod tests { // something to transcode and resolves. let source_fetches = source._track.dynamic(); let server = tokio::spawn(async move { - while let Ok(request) = source_fetches.requested_group().await { - let mut group = request.accept(None).unwrap(); + while let Ok(request) = source_fetches.requested_fill().await { + let mut group = request.accept(request.start().group, None).unwrap(); write_keyframe(&mut group); group.finish().unwrap(); } @@ -1504,32 +1504,31 @@ mod tests { rung.query().await.unwrap(); let fetching = rung.fetch_group(7, None); - let abandoned = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_group()) + let abandoned = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_fill()) .await .expect("the fetch never reached the source") .expect("the source track closed"); - assert_eq!(abandoned.sequence(), 7); + assert_eq!(abandoned.start().group, 7); // Leave before a single source frame exists. Encoding can only happen // if the handler keeps this fetch. drop(fetching); - tokio::time::timeout(std::time::Duration::from_secs(5), abandoned.demand().unused()) + tokio::time::timeout(std::time::Duration::from_secs(5), abandoned.unused()) .await - .expect("the abandoned fetch kept its source fetch") - .expect("the source fetch closed instead of going unused"); + .expect("the abandoned fetch kept its source fetch"); // The handler dropped the output request with the caller, so this is a // fresh request rather than a join of the one still encoding. let retry = rung.fetch_group(7, None); - let fresh = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_group()) + let fresh = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_fill()) .await .expect("the retry never fetched the source") .expect("the source track closed"); - assert_eq!(fresh.sequence(), 7); + assert_eq!(fresh.start().group, 7); drop(abandoned); let mut group = fresh - .accept(None) + .accept(7, None) .expect("the fresh request lost to the abandoned encode"); write_keyframe(&mut group); group.finish().unwrap(); diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index 76df66d8b0c..d6db0af6a59 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -8,7 +8,7 @@ //! group for group until the track goes `unused` again. The group open at //! that point is finished first, so a later reader of that sequence gets the //! whole group rather than an aborted one. -//! - A fetch of a specific group (`requested_group`) fetches that same group +//! - A fetch of a specific group (`requested_fill`) fetches that same group //! from the source and transcodes just that group with a fresh encoder. A //! fetch that starts mid-group is refused: a fresh encode's frames are only //! valid after the head that same encode produced. A fetch every caller @@ -388,9 +388,9 @@ async fn live(rung: &Rung, producer: &mut moq_net::track::Producer) -> Result permit.expect("the semaphore stays open"), }; - let request = tokio::select! { + let fill = tokio::select! { biased; () = retire.fired() => { retired = true; break; }, _ = finishing.wait_for(|finished| *finished) => break, - request = dynamic.requested_group() => match request { - Ok(request) => request, + fill = dynamic.requested_fill() => match fill { + Ok(fill) => fill, // The output track closed; nothing more to serve. Err(_) => break, }, }; - spawn_fetch(&mut tasks, rung.clone(), request, permit); + spawn_fill(&mut tasks, rung.clone(), fill, permit); } if retired { // A consumer that asked for a group before retirement is already waiting on - // it, whether or not this loop had reached it yet: the request queues on the + // it, whether or not this loop had reached it yet: the fill queues on the // dynamic handler the moment the fetch is made. Take what is queued now, so // dropping the handler below does not cancel a fetch that beat retirement. // @@ -452,14 +452,14 @@ async fn fetches( // handler admits a cache miss for as long as it is alive, so a drain that // waited for a slot with it still open would keep taking those in too and // the rung would never finish. Dropping it closes admission and releases - // whatever arrived after the pass; the requests already popped are held + // whatever arrived after the pass; the fills already popped are held // here, so they are unaffected. let mut queued = Vec::new(); loop { tokio::select! { biased; - request = dynamic.requested_group() => match request { - Ok(request) => queued.push(request), + fill = dynamic.requested_fill() => match fill { + Ok(fill) => queued.push(fill), // The output track closed; nothing left to serve. Err(_) => break, }, @@ -469,9 +469,9 @@ async fn fetches( } drop(dynamic); - for request in queued { + for fill in queued { let permit = limit.clone().acquire_owned().await.expect("the semaphore stays open"); - spawn_fetch(&mut tasks, rung.clone(), request, permit); + spawn_fill(&mut tasks, rung.clone(), fill, permit); } // Finish every group the handler accepted, or its producer stays open and @@ -491,66 +491,61 @@ async fn fetches( Ok(()) } -/// Spawn one fetch, holding its concurrency slot for the life of the task. -fn spawn_fetch( +/// Spawn one fill, holding its concurrency slot for the life of the task. +fn spawn_fill( tasks: &mut tokio::task::JoinSet<()>, rung: Rung, - request: moq_net::group::Request, + fill: moq_net::track::Fill, permit: tokio::sync::OwnedSemaphorePermit, ) { tasks.spawn(async move { let _permit = permit; - let sequence = request.sequence(); - if let Err(err) = fetch(rung, request).await { - tracing::warn!(%err, sequence, "transcode fetch failed"); + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + if let Err(err) = fetch(&rung, &fill, sequence).await { + tracing::warn!(%err, sequence, "transcode fetch failed"); + } } }); } -/// Transcode one specifically requested group, fetching it from the source. +/// Transcode group `sequence` of a fill, fetching it from the source. /// -/// A failure while someone still wants the group rejects the request: a source -/// fetch error passes through, and a pipeline or container error rejects with -/// [`moq_net::Error::Cancel`]. Dropping it would auto-reject with [`moq_net::Error::Dropped`], which -/// reads as "the handler vanished" and hides the actual failure. Once nobody -/// wants the group, the request is dropped instead: the last caller already -/// withdrew the attempt, and accepting afterwards would cache the group under a -/// fresh request for the same sequence, whose own accept is then -/// [`moq_net::Error::Duplicate`]. -async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error> { +/// A failure while someone still wants the group drops it. Once nobody wants the +/// fill, it is left unanswered instead: the last caller already withdrew it, and +/// accepting afterwards would cache the group a fresh fill is about to serve. +async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Result<(), Error> { + let reject = || fill.drop_groups(sequence..sequence + 1); // A fresh encode of this group need not match the bytes the reader's head // came from (the live encoder, or an earlier fetch), so its tail cannot // continue that head. Refuse it so the reader moves on to the next group // boundary instead of decoding a splice. - if request.frame_start() != 0 { - tracing::debug!( - sequence = request.sequence(), - frame = request.frame_start(), - "refusing a fetch that starts mid-group" - ); - request.reject(moq_net::Error::NotFound); + let start = fill.start(); + if start.group == sequence && start.frame != 0 { + tracing::debug!(sequence, frame = start.frame, "refusing a fetch that starts mid-group"); + reject(); return Ok(()); } - // Watched until accept. After that the callers resolve from the cache, and - // this handle going unused is them picking the group up, not abandoning it. - let demand = request.demand(); - let track = match rung.broadcast.track(&rung.source) { Ok(track) => track, Err(err) => { - request.reject(err.clone()); + reject(); return Err(err.into()); } }; - let options = moq_net::group::Fetch::default().with_priority(request.priority()); + let options = moq_net::group::Fetch::default().with_priority(fill.priority()); let mut source = tokio::select! { biased; - _ = demand.unused() => return abandon(request), - source = track.fetch_group(request.sequence(), options) => match source { + _ = fill.unused() => return abandon(sequence), + source = track.fetch_group(sequence, options) => match source { Ok(source) => source, Err(err) => { - request.reject(err.clone()); + reject(); return Err(err.into()); } }, @@ -565,7 +560,7 @@ async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error let pipeline = match rung.pipeline().await { Ok(pipeline) => pipeline, Err(err) => { - request.reject(moq_net::Error::Cancel); + reject(); return Err(err); } }; @@ -573,18 +568,18 @@ async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error let container = match rung.container() { Ok(container) => container, Err(err) => { - request.reject(moq_net::Error::Cancel); + reject(); return Err(err); } }; // Accepting is what publishes the group. One last look, so a caller who - // left while the pipeline was opening does not race a fresh request. - if !demand.is_used() { - return abandon(request); + // left while the pipeline was opening does not race a fresh fill. + if !fill.is_used() { + return abandon(sequence); } - let output = match request.accept(None) { + let output = match fill.accept(sequence, None) { Ok(output) => output, Err(err) => return Err(err.into()), }; @@ -596,14 +591,10 @@ async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error Ok(()) } -/// Drop a fetch nobody is waiting on. -/// -/// Dropping rejects with [`moq_net::Error::Dropped`]. No caller is left to see -/// it, and keeping the request would let this encode publish the group a fresh -/// request is about to serve. -fn abandon(request: moq_net::group::Request) -> Result<(), Error> { - tracing::debug!(sequence = request.sequence(), "dropping a fetch nobody wants"); - drop(request); +/// Leave a fill nobody is waiting on: it was withdrawn, so this encode must not publish +/// the group a fresh fill is about to serve. +fn abandon(sequence: u64) -> Result<(), Error> { + tracing::debug!(sequence, "leaving a fetch nobody wants"); Ok(()) } diff --git a/rs/moq-video/src/decode/consumer.rs b/rs/moq-video/src/decode/consumer.rs index e982a8ac425..386d13e52a0 100644 --- a/rs/moq-video/src/decode/consumer.rs +++ b/rs/moq-video/src/decode/consumer.rs @@ -672,7 +672,8 @@ mod tests { let subscription = published.subscription().expect("the consumer subscribed"); assert_eq!( - subscription.max_delay, max_delay, + subscription.max_delay, + Some(max_delay), "the age budget did not reach the publisher" ); @@ -1126,7 +1127,7 @@ mod tests { ) .await; assert_eq!( - published.subscription().map(|sub| sub.max_delay), + published.subscription().and_then(|sub| sub.max_delay), Some(WARMUP), "the two subscriptions did not share one request at the larger budget" ); @@ -1283,7 +1284,7 @@ mod tests { .await .unwrap(); assert!(matches!(consumer.lanes[Lane::Max as usize], Slot::Closed)); - assert_eq!(track.subscription().map(|sub| sub.max_delay), Some(max_delay)); + assert_eq!(track.subscription().and_then(|sub| sub.max_delay), Some(max_delay)); } } From 867d6eac01e28e00f999d81e7a3053a0f5c80ff8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 23:05:50 -0700 Subject: [PATCH 06/48] wip: bindings serve fills --- dart/moq/lib/src/aliases.dart | 9 +- dart/moq/lib/src/durations.dart | 15 +- dart/moq_ffi/lib/src/moq.dart | 621 +++++++++--------- go/wrapper/doc.go | 2 +- go/wrapper/moq_test.go | 21 +- go/wrapper/publish.go | 114 ++-- go/wrapper/records.go | 13 +- .../kotlin/dev/moq/Aliases.kt | 8 +- .../kotlin/dev/moq/Durations.kt | 10 +- .../jvmAndAndroidMain/kotlin/dev/moq/Flows.kt | 4 +- py/moq-rs/docs/index.md | 3 +- py/moq-rs/moq/__init__.py | 6 +- py/moq-rs/moq/_records.py | 7 +- py/moq-rs/moq/publish.py | 110 ++-- py/moq-rs/tests/test_local.py | 28 +- rs/moq-ffi/src/consumer.rs | 2 +- swift/Sources/Moq/Dynamic.swift | 104 +-- 17 files changed, 532 insertions(+), 545 deletions(-) diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index ee9d97750c1..fde946a5f41 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -87,7 +87,7 @@ typedef TrackProducer = MoqTrackProducer; /// A subscriber-requested track not yet accepted: accept it for a [TrackProducer] or abort it. typedef TrackRequest = MoqTrackRequest; -/// A stream of uncached group requests for one track, for serving fetches on demand. +/// A stream of the fills readers of one track need: runs of groups the cache lacks. typedef TrackDynamic = MoqTrackDynamic; /// A watch-only handle to whether a published track has subscribers; holding it keeps nothing open. @@ -96,11 +96,8 @@ typedef TrackDemand = MoqTrackDemand; /// The read side of a raw track: yields groups in sequence order, skipping ahead if it falls behind. typedef TrackConsumer = MoqTrackConsumer; -/// A request to produce one uncached group for a fetch consumer. -typedef GroupRequest = MoqGroupRequest; - -/// A watch-only handle to whether any caller still wants a requested group; holding it keeps nothing open. -typedef GroupDemand = MoqGroupDemand; +/// A run of groups readers miss: accept each group you have and drop the rest. +typedef Fill = MoqFill; /// The write side of a single group: append frames to it. typedef GroupProducer = MoqGroupProducer; diff --git a/dart/moq/lib/src/durations.dart b/dart/moq/lib/src/durations.dart index ca766268bbc..c41889dfa1f 100644 --- a/dart/moq/lib/src/durations.dart +++ b/dart/moq/lib/src/durations.dart @@ -28,8 +28,19 @@ extension BackoffDuration on MoqBackoff { /// Duration views over the subscription knobs. extension SubscriptionDuration on MoqSubscription { - /// Upper bound on buffering before a stalled group is skipped. - Duration get maxDelay => Duration(microseconds: maxDelayUs); + /// Upper bound on buffering before a stalled group is skipped, or null for the + /// publisher's max age alone. + Duration? get maxDelay => + maxDelayUs == null ? null : Duration(microseconds: maxDelayUs!); +} + +/// Duration views over a fill. +extension FillDuration on MoqFill { + /// The most tolerant age budget among its readers, or null for the publisher's max age alone. + Duration? get maxDelay { + final us = maxDelayUs(); + return us == null ? null : Duration(microseconds: us); + } } /// Duration views over the publisher-side track settings. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index a8739d693a7..e7b3144871b 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -617,7 +617,7 @@ class FfiConverterMoqRange { class MoqSubscription { final int priority; - final int maxDelayUs; + final int? maxDelayUs; final List? ranges; final MoqOrder? order; MoqSubscription({ @@ -640,7 +640,7 @@ class FfiConverterMoqSubscription { ); final priority = priority_lifted.value; new_offset += priority_lifted.bytesRead; - final maxDelayUs_lifted = FfiConverterUInt64.read( + final maxDelayUs_lifted = FfiConverterOptionalUInt64.read( Uint8List.view(buf.buffer, new_offset), ); final maxDelayUs = maxDelayUs_lifted.value; @@ -669,7 +669,7 @@ class FfiConverterMoqSubscription { static RustBuffer lower(MoqSubscription value) { final total_length = FfiConverterUInt8.allocationSize(value.priority) + - FfiConverterUInt64.allocationSize(value.maxDelayUs) + + FfiConverterOptionalUInt64.allocationSize(value.maxDelayUs) + FfiConverterOptionalSequenceMoqRange.allocationSize(value.ranges) + FfiConverterOptionalMoqOrder.allocationSize(value.order) + 0; @@ -684,7 +684,7 @@ class FfiConverterMoqSubscription { value.priority, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterUInt64.write( + new_offset += FfiConverterOptionalUInt64.write( value.maxDelayUs, Uint8List.view(buf.buffer, new_offset), ); @@ -701,7 +701,7 @@ class FfiConverterMoqSubscription { static int allocationSize(MoqSubscription value) { return FfiConverterUInt8.allocationSize(value.priority) + - FfiConverterUInt64.allocationSize(value.maxDelayUs) + + FfiConverterOptionalUInt64.allocationSize(value.maxDelayUs) + FfiConverterOptionalSequenceMoqRange.allocationSize(value.ranges) + FfiConverterOptionalMoqOrder.allocationSize(value.order) + 0; @@ -8428,116 +8428,6 @@ class FfiConverterMoqTrackConsumer { } } -abstract class MoqGroupDemandInterface { - bool isUsed(); - int sequence(); - Future unused(); - Future used(); -} - -final _MoqGroupDemandFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupdemand(ptr, status)); -}); - -class MoqGroupDemand implements MoqGroupDemandInterface { - late final Pointer _ptr; - bool _isDisposed = false; - MoqGroupDemand._(this._ptr) { - _MoqGroupDemandFinalizer.attach(this, _ptr, detach: this); - } - factory MoqGroupDemand.lift(Pointer ptr) { - return MoqGroupDemand._(ptr); - } - Pointer uniffiClonePointer() { - if (_isDisposed) { - throw StateError("Object has already been disposed"); - } - return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqgroupdemand(_ptr, status), - ); - } - - void dispose() { - if (_isDisposed) { - return; - } - _isDisposed = true; - _MoqGroupDemandFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupdemand(_ptr, status)); - } - - bool isUsed() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgroupdemand_is_used( - uniffiClonePointer(), - status, - ), - FfiConverterBool.lift, - null, - ); - } - - int sequence() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgroupdemand_sequence( - uniffiClonePointer(), - status, - ), - FfiConverterUInt64.lift, - null, - ); - } - - Future unused() { - return uniffiRustCallAsync( - () => - uniffi_moq_ffi_fn_method_moqgroupdemand_unused(uniffiClonePointer()), - ffi_moq_ffi_rust_future_poll_void, - ffi_moq_ffi_rust_future_complete_void, - ffi_moq_ffi_rust_future_free_void, - (_) {}, - moqExceptionErrorHandler, - ); - } - - Future used() { - return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqgroupdemand_used(uniffiClonePointer()), - ffi_moq_ffi_rust_future_poll_void, - ffi_moq_ffi_rust_future_complete_void, - ffi_moq_ffi_rust_future_free_void, - (_) {}, - moqExceptionErrorHandler, - ); - } -} - -class FfiConverterMoqGroupDemand { - static MoqGroupDemand lift(Pointer ptr) { - return MoqGroupDemand.lift(ptr); - } - - static Pointer lower(MoqGroupDemand value) { - return value.uniffiClonePointer(); - } - - static int allocationSize(MoqGroupDemand value) { - return 8; - } - - static LiftRetVal read(Uint8List buf) { - final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); - final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqGroupDemand.lift(pointer), 8); - } - - static int write(MoqGroupDemand value, Uint8List buf) { - final handle = lower(value); - buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); - return 8; - } -} - abstract class MoqTrackDemandInterface { String name(); int? wanted(); @@ -11057,34 +10947,38 @@ class FfiConverterMoqBroadcastProducer { } } -abstract class MoqGroupProducerInterface { - void abort({required int errorCode}); - MoqGroupConsumer consume(); - void finish(); - int sequence(); - void writeFrame({required MoqFrame frame}); +abstract class MoqFillInterface { + MoqGroupProducer accept({required int sequence}); + void dropGroups({required int start, required int end}); + int endGroup(); + bool isUsed(); + int? maxDelayUs(); + int? next(); + MoqOrder order(); + int priority(); + int startFrame(); + int startGroup(); + Future unused(); } -final _MoqGroupProducerFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(ptr, status)); +final _MoqFillFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqfill(ptr, status)); }); -class MoqGroupProducer implements MoqGroupProducerInterface { +class MoqFill implements MoqFillInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqGroupProducer._(this._ptr) { - _MoqGroupProducerFinalizer.attach(this, _ptr, detach: this); + MoqFill._(this._ptr) { + _MoqFillFinalizer.attach(this, _ptr, detach: this); } - factory MoqGroupProducer.lift(Pointer ptr) { - return MoqGroupProducer._(ptr); + factory MoqFill.lift(Pointer ptr) { + return MoqFill._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } - return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqgroupproducer(_ptr, status), - ); + return rustCall((status) => uniffi_moq_ffi_fn_clone_moqfill(_ptr, status)); } void dispose() { @@ -11092,43 +10986,98 @@ class MoqGroupProducer implements MoqGroupProducerInterface { return; } _isDisposed = true; - _MoqGroupProducerFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(_ptr, status)); + _MoqFillFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqfill(_ptr, status)); } - void abort({required int errorCode}) { + MoqGroupProducer accept({required int sequence}) { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_accept( + uniffiClonePointer(), + FfiConverterUInt64.lower(sequence), + status, + ), + FfiConverterMoqGroupProducer.lift, + moqExceptionErrorHandler, + ); + } + + void dropGroups({required int start, required int end}) { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgroupproducer_abort( + uniffi_moq_ffi_fn_method_moqfill_drop_groups( uniffiClonePointer(), - FfiConverterUInt16.lower(errorCode), + FfiConverterUInt64.lower(start), + FfiConverterUInt64.lower(end), status, ); - }, moqExceptionErrorHandler); + }, null); } - MoqGroupConsumer consume() { + int endGroup() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_consume( + (status) => uniffi_moq_ffi_fn_method_moqfill_end_group( uniffiClonePointer(), status, ), - FfiConverterMoqGroupConsumer.lift, - moqExceptionErrorHandler, + FfiConverterUInt64.lift, + null, ); } - void finish() { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgroupproducer_finish( + bool isUsed() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_is_used( uniffiClonePointer(), status, - ); - }, moqExceptionErrorHandler); + ), + FfiConverterBool.lift, + null, + ); } - int sequence() { + int? maxDelayUs() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( + (status) => uniffi_moq_ffi_fn_method_moqfill_max_delay_us( + uniffiClonePointer(), + status, + ), + FfiConverterOptionalUInt64.lift, + null, + ); + } + + int? next() { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqfill_next(uniffiClonePointer(), status), + FfiConverterOptionalUInt64.lift, + null, + ); + } + + MoqOrder order() { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqfill_order(uniffiClonePointer(), status), + FfiConverterMoqOrder.lift, + null, + ); + } + + int priority() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_priority( + uniffiClonePointer(), + status, + ), + FfiConverterUInt8.lift, + null, + ); + } + + int startFrame() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_start_frame( uniffiClonePointer(), status, ), @@ -11137,70 +11086,82 @@ class MoqGroupProducer implements MoqGroupProducerInterface { ); } - void writeFrame({required MoqFrame frame}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( + int startGroup() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_start_group( uniffiClonePointer(), - FfiConverterMoqFrame.lower(frame), status, - ); - }, moqExceptionErrorHandler); + ), + FfiConverterUInt64.lift, + null, + ); + } + + Future unused() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqfill_unused(uniffiClonePointer()), + ffi_moq_ffi_rust_future_poll_void, + ffi_moq_ffi_rust_future_complete_void, + ffi_moq_ffi_rust_future_free_void, + (_) {}, + moqExceptionErrorHandler, + ); } } -class FfiConverterMoqGroupProducer { - static MoqGroupProducer lift(Pointer ptr) { - return MoqGroupProducer.lift(ptr); +class FfiConverterMoqFill { + static MoqFill lift(Pointer ptr) { + return MoqFill.lift(ptr); } - static Pointer lower(MoqGroupProducer value) { + static Pointer lower(MoqFill value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqGroupProducer value) { + static int allocationSize(MoqFill value) { return 8; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqGroupProducer.lift(pointer), 8); + return LiftRetVal(MoqFill.lift(pointer), 8); } - static int write(MoqGroupProducer value, Uint8List buf) { + static int write(MoqFill value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; } } -abstract class MoqGroupRequestInterface { +abstract class MoqGroupProducerInterface { void abort({required int errorCode}); - MoqGroupProducer accept(); - MoqGroupDemand demand(); - int priority(); + MoqGroupConsumer consume(); + void finish(); int sequence(); + void writeFrame({required MoqFrame frame}); } -final _MoqGroupRequestFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqgrouprequest(ptr, status)); +final _MoqGroupProducerFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(ptr, status)); }); -class MoqGroupRequest implements MoqGroupRequestInterface { +class MoqGroupProducer implements MoqGroupProducerInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqGroupRequest._(this._ptr) { - _MoqGroupRequestFinalizer.attach(this, _ptr, detach: this); + MoqGroupProducer._(this._ptr) { + _MoqGroupProducerFinalizer.attach(this, _ptr, detach: this); } - factory MoqGroupRequest.lift(Pointer ptr) { - return MoqGroupRequest._(ptr); + factory MoqGroupProducer.lift(Pointer ptr) { + return MoqGroupProducer._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqgrouprequest(_ptr, status), + (status) => uniffi_moq_ffi_fn_clone_moqgroupproducer(_ptr, status), ); } @@ -11209,13 +11170,13 @@ class MoqGroupRequest implements MoqGroupRequestInterface { return; } _isDisposed = true; - _MoqGroupRequestFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqgrouprequest(_ptr, status)); + _MoqGroupProducerFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(_ptr, status)); } void abort({required int errorCode}) { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgrouprequest_abort( + uniffi_moq_ffi_fn_method_moqgroupproducer_abort( uniffiClonePointer(), FfiConverterUInt16.lower(errorCode), status, @@ -11223,71 +11184,68 @@ class MoqGroupRequest implements MoqGroupRequestInterface { }, moqExceptionErrorHandler); } - MoqGroupProducer accept() { + MoqGroupConsumer consume() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_accept( + (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_consume( uniffiClonePointer(), status, ), - FfiConverterMoqGroupProducer.lift, + FfiConverterMoqGroupConsumer.lift, moqExceptionErrorHandler, ); } - MoqGroupDemand demand() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_demand( + void finish() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqgroupproducer_finish( uniffiClonePointer(), status, - ), - FfiConverterMoqGroupDemand.lift, - moqExceptionErrorHandler, - ); + ); + }, moqExceptionErrorHandler); } - int priority() { + int sequence() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_priority( + (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( uniffiClonePointer(), status, ), - FfiConverterUInt8.lift, + FfiConverterUInt64.lift, null, ); } - int sequence() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_sequence( + void writeFrame({required MoqFrame frame}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( uniffiClonePointer(), + FfiConverterMoqFrame.lower(frame), status, - ), - FfiConverterUInt64.lift, - null, - ); + ); + }, moqExceptionErrorHandler); } } -class FfiConverterMoqGroupRequest { - static MoqGroupRequest lift(Pointer ptr) { - return MoqGroupRequest.lift(ptr); +class FfiConverterMoqGroupProducer { + static MoqGroupProducer lift(Pointer ptr) { + return MoqGroupProducer.lift(ptr); } - static Pointer lower(MoqGroupRequest value) { + static Pointer lower(MoqGroupProducer value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqGroupRequest value) { + static int allocationSize(MoqGroupProducer value) { return 8; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqGroupRequest.lift(pointer), 8); + return LiftRetVal(MoqGroupProducer.lift(pointer), 8); } - static int write(MoqGroupRequest value, Uint8List buf) { + static int write(MoqGroupProducer value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; @@ -11296,7 +11254,7 @@ class FfiConverterMoqGroupRequest { abstract class MoqTrackDynamicInterface { void cancel(); - Future requestedGroup(); + Future requestedFill(); } final _MoqTrackDynamicFinalizer = Finalizer>((ptr) { @@ -11339,15 +11297,15 @@ class MoqTrackDynamic implements MoqTrackDynamicInterface { }, null); } - Future requestedGroup() { + Future requestedFill() { return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_group( + () => uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_fill( uniffiClonePointer(), ), ffi_moq_ffi_rust_future_poll_u64, ffi_moq_ffi_rust_future_complete_u64, ffi_moq_ffi_rust_future_free_u64, - (ptr) => FfiConverterMoqGroupRequest.lift(Pointer.fromAddress(ptr)), + (ptr) => FfiConverterMoqFill.lift(Pointer.fromAddress(ptr)), moqExceptionErrorHandler, ); } @@ -15502,48 +15460,6 @@ external void uniffi_moq_ffi_fn_method_moqtrackconsumer_update( Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( - assetId: _uniffiAssetId, -) -external Pointer uniffi_moq_ffi_fn_clone_moqgroupdemand( - Pointer handle, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_free_moqgroupdemand( - Pointer handle, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external int uniffi_moq_ffi_fn_method_moqgroupdemand_is_used( - Pointer ptr, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external int uniffi_moq_ffi_fn_method_moqgroupdemand_sequence( - Pointer ptr, - Pointer uniffiStatus, -); - -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqgroupdemand_unused( - Pointer ptr, -); - -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqgroupdemand_used( - Pointer ptr, -); - @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -16667,7 +16583,7 @@ external void uniffi_moq_ffi_fn_method_moqbroadcastproducer_unannounce( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqgroupproducer( +external Pointer uniffi_moq_ffi_fn_clone_moqfill( Pointer handle, Pointer uniffiStatus, ); @@ -16675,32 +16591,74 @@ external Pointer uniffi_moq_ffi_fn_clone_moqgroupproducer( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqgroupproducer( +external void uniffi_moq_ffi_fn_free_moqfill( Pointer handle, Pointer uniffiStatus, ); -@Native, Uint16, Pointer)>( +@Native Function(Pointer, Uint64, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgroupproducer_abort( +external Pointer uniffi_moq_ffi_fn_method_moqfill_accept( Pointer ptr, - int error_code, + int sequence, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Uint64, Uint64, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqgroupproducer_consume( +external void uniffi_moq_ffi_fn_method_moqfill_drop_groups( Pointer ptr, + int start, + int end, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( +external int uniffi_moq_ffi_fn_method_moqfill_end_group( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqfill_is_used( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqfill_max_delay_us( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqfill_next( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqfill_order( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqfill_priority( Pointer ptr, Pointer uniffiStatus, ); @@ -16708,24 +16666,28 @@ external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( +external int uniffi_moq_ffi_fn_method_moqfill_start_frame( Pointer ptr, Pointer uniffiStatus, ); -@Native, RustBuffer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( +external int uniffi_moq_ffi_fn_method_moqfill_start_group( Pointer ptr, - RustBuffer frame, Pointer uniffiStatus, ); +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqfill_unused( + Pointer ptr, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqgrouprequest( +external Pointer uniffi_moq_ffi_fn_clone_moqgroupproducer( Pointer handle, Pointer uniffiStatus, ); @@ -16733,7 +16695,7 @@ external Pointer uniffi_moq_ffi_fn_clone_moqgrouprequest( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqgrouprequest( +external void uniffi_moq_ffi_fn_free_moqgroupproducer( Pointer handle, Pointer uniffiStatus, ); @@ -16741,7 +16703,7 @@ external void uniffi_moq_ffi_fn_free_moqgrouprequest( @Native, Uint16, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgrouprequest_abort( +external void uniffi_moq_ffi_fn_method_moqgroupproducer_abort( Pointer ptr, int error_code, Pointer uniffiStatus, @@ -16750,32 +16712,33 @@ external void uniffi_moq_ffi_fn_method_moqgrouprequest_abort( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqgrouprequest_accept( +external Pointer uniffi_moq_ffi_fn_method_moqgroupproducer_consume( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqgrouprequest_demand( +external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqgrouprequest_priority( +external int uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, RustBuffer, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqgrouprequest_sequence( +external void uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( Pointer ptr, + RustBuffer frame, Pointer uniffiStatus, ); @@ -16804,7 +16767,7 @@ external void uniffi_moq_ffi_fn_method_moqtrackdynamic_cancel( ); @Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_group( +external Pointer uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_fill( Pointer ptr, ); @@ -17954,18 +17917,6 @@ external int uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_group(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackconsumer_update(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupdemand_is_used(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupdemand_sequence(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupdemand_unused(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupdemand_used(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackdemand_name(); @@ -18180,40 +18131,58 @@ uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_track(); external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_unannounce(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_abort(); +external int uniffi_moq_ffi_checksum_method_moqfill_accept(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_consume(); +external int uniffi_moq_ffi_checksum_method_moqfill_drop_groups(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_finish(); +external int uniffi_moq_ffi_checksum_method_moqfill_end_group(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence(); +external int uniffi_moq_ffi_checksum_method_moqfill_is_used(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame(); +external int uniffi_moq_ffi_checksum_method_moqfill_max_delay_us(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_next(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_order(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_abort(); +external int uniffi_moq_ffi_checksum_method_moqfill_priority(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_accept(); +external int uniffi_moq_ffi_checksum_method_moqfill_start_frame(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_demand(); +external int uniffi_moq_ffi_checksum_method_moqfill_start_group(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_priority(); +external int uniffi_moq_ffi_checksum_method_moqfill_unused(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_sequence(); +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_abort(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_consume(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_finish(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_group(); +external int uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_fill(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackproducer_abort(); @@ -18605,18 +18574,6 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackconsumer_update() != 39083) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupdemand_is_used() != 55523) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqgroupdemand_sequence() != 34558) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqgroupdemand_unused() != 1305) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqgroupdemand_used() != 16710) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqtrackdemand_name() != 14603) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -18845,41 +18802,59 @@ void _checkApiChecksums() { 49647) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_abort() != 59787) { + if (uniffi_moq_ffi_checksum_method_moqfill_accept() != 63990) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_consume() != 53274) { + if (uniffi_moq_ffi_checksum_method_moqfill_drop_groups() != 7289) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_finish() != 61241) { + if (uniffi_moq_ffi_checksum_method_moqfill_end_group() != 42504) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence() != 21067) { + if (uniffi_moq_ffi_checksum_method_moqfill_is_used() != 6429) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame() != 42053) { + if (uniffi_moq_ffi_checksum_method_moqfill_max_delay_us() != 40340) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_next() != 7725) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_order() != 32232) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_abort() != 26970) { + if (uniffi_moq_ffi_checksum_method_moqfill_priority() != 60321) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_accept() != 48242) { + if (uniffi_moq_ffi_checksum_method_moqfill_start_frame() != 29713) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_demand() != 31288) { + if (uniffi_moq_ffi_checksum_method_moqfill_start_group() != 8622) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_priority() != 1745) { + if (uniffi_moq_ffi_checksum_method_moqfill_unused() != 3173) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_sequence() != 29523) { + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_abort() != 59787) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_consume() != 53274) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_finish() != 61241) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence() != 21067) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame() != 42053) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel() != 57913) { + if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel() != 15841) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_group() != - 63983) { + if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_fill() != + 22958) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_abort() != 37537) { diff --git a/go/wrapper/doc.go b/go/wrapper/doc.go index e8294ca06e9..ad9b446e490 100644 --- a/go/wrapper/doc.go +++ b/go/wrapper/doc.go @@ -25,7 +25,7 @@ // // What a cancel tears down depends on the call. A one-shot call (a subscribe, a // fetch, RequestBroadcast, Resolve, a TrackDemand's WantedChanged, a -// GroupDemand's Used/Unused, Server.Accept) aborts on its own and leaves the +// Fill's Unused, Server.Accept) aborts on its own and leaves the // object it was made on usable, so the same // broadcast, producer, or server takes the next call. A stream read (any Next, // RecvGroup, ReadFrame, or the iterators over them) instead cancels the stream diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 02858ed518e..a8fb07fbe86 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -532,25 +532,21 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { result <- fetchResult{group: group, err: err} }() - request, err := dynamic.RequestedGroup(ctx) + fill, err := dynamic.RequestedFill(ctx) if err != nil { t.Fatal(err) } - if request.Sequence() != 7 || request.Priority() != 11 { - t.Fatalf("unexpected request: sequence=%d priority=%d", request.Sequence(), request.Priority()) + if fill.StartGroup() != 7 || fill.EndGroup() != 8 || fill.Priority() != 11 { + t.Fatalf("unexpected fill: start=%d end=%d priority=%d", fill.StartGroup(), fill.EndGroup(), fill.Priority()) } - demand, err := request.Demand() - if err != nil { - t.Fatal(err) - } - if demand.Sequence() != 7 || !demand.IsUsed() { - t.Fatalf("unexpected demand: sequence=%d used=%v", demand.Sequence(), demand.IsUsed()) + if next := fill.Next(); next == nil || *next != 7 || !fill.IsUsed() { + t.Fatalf("unexpected fill: next=%v used=%v", next, fill.IsUsed()) } - produced, err := request.Accept() + produced, err := fill.Accept(7) if err != nil { t.Fatal(err) } - if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), Timestamp: ts(time.Duration(request.Sequence()*20_000) * time.Microsecond)}); err != nil { + if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), Timestamp: ts(7 * 20_000 * time.Microsecond)}); err != nil { t.Fatal(err) } if err := produced.Finish(); err != nil { @@ -767,8 +763,9 @@ func TestAscendingSubscriptionReadsCachedBacklogOldestFirst(t *testing.T) { } order := moq.OrderAscending + maxDelay := 10 * time.Second consumer, err := track.Consume(&moq.Subscription{ - MaxDelay: 10 * time.Second, + MaxDelay: &maxDelay, Ranges: []moq.Range{{Start: moq.AnchorRelative{Groups: 2}}}, Order: &order, }) diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 172a43dc645..ee1d5f1131c 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -3,6 +3,7 @@ package moq import ( "context" "iter" + "time" ffi "moq.dev/moq-ffi/moq" "moq.dev/moq/internal/bridge" @@ -302,94 +303,97 @@ func (g *GroupProducer) Abort(errorCode uint16) error { return g.inner.Abort(errorCode) } -// TrackDynamic yields uncached groups requested by fetch consumers. +// TrackDynamic yields the fills readers of one track need: runs of groups the cache lacks. type TrackDynamic struct { inner *ffi.MoqTrackDynamic } -// RequestedGroup waits for the next uncached group request. -func (d *TrackDynamic) RequestedGroup(ctx context.Context) (*GroupRequest, error) { - inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedGroup) +// RequestedFill waits for the next fill. +func (d *TrackDynamic) RequestedFill(ctx context.Context) (*Fill, error) { + inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedFill) if err != nil { return nil, err } - return &GroupRequest{inner: inner}, nil + return &Fill{inner: inner}, nil } -// All ranges over uncached group requests until the dynamic source ends. -func (d *TrackDynamic) All(ctx context.Context) iter.Seq2[*GroupRequest, error] { - return bridge.Seq(ctx, d.RequestedGroup) +// All ranges over fills until the dynamic source ends. +func (d *TrackDynamic) All(ctx context.Context) iter.Seq2[*Fill, error] { + return bridge.Seq(ctx, d.RequestedFill) } -// Cancel stops current and future requested-group waits. +// Cancel stops current and future requested-fill waits. func (d *TrackDynamic) Cancel() { d.inner.Cancel() } -// GroupRequest requests one uncached group from a track producer. -type GroupRequest struct { - inner *ffi.MoqGroupRequest +// Fill is a run of groups readers miss, for a TrackDynamic to serve. +// +// Accept each group you have and DropGroups the rest; Next names what is still +// owed, in the fill's order. Releasing the fill names whatever is left as dropped +// while a reader still wants it; once nobody does, it is withdrawn, so stop +// serving it when Unused returns. +type Fill struct { + inner *ffi.MoqFill } -// Sequence is the requested group sequence within the track. -func (r *GroupRequest) Sequence() uint64 { - return r.inner.Sequence() +// StartGroup is the first group to fill. +func (f *Fill) StartGroup() uint64 { + return f.inner.StartGroup() } -// Priority is the consumer's delivery priority for this fetch. -func (r *GroupRequest) Priority() uint8 { - return r.inner.Priority() +// StartFrame is the frame within the first group to start from. +func (f *Fill) StartFrame() uint64 { + return f.inner.StartFrame() } -// Demand returns a watch-only handle to whether any caller still wants the group. -func (r *GroupRequest) Demand() (*GroupDemand, error) { - inner, err := r.inner.Demand() - if err != nil { - return nil, err - } - return &GroupDemand{inner: inner}, nil +// EndGroup is the exclusive end group. +func (f *Fill) EndGroup() uint64 { + return f.inner.EndGroup() } -// Accept accepts the request and returns a producer for the group. -func (r *GroupRequest) Accept() (*GroupProducer, error) { - inner, err := r.inner.Accept() - if err != nil { - return nil, err - } - return &GroupProducer{inner: inner}, nil +// Priority is the most urgent delivery priority among the readers waiting on it. +func (f *Fill) Priority() uint8 { + return f.inner.Priority() } -// Abort rejects the fetch with an application error code. -func (r *GroupRequest) Abort(errorCode uint16) error { - return r.inner.Abort(errorCode) +// MaxDelay is the most tolerant age budget among its readers, or nil for the +// publisher's max age alone. +func (f *Fill) MaxDelay() *time.Duration { + return optFromMicros(f.inner.MaxDelayUs()) } -// GroupDemand watches the callers waiting on a requested group. -// -// It is weak: holding it does not keep the request alive. The last caller to -// leave withdraws the request, so once unused, demand never returns: drop the -// request. Waits fail with ErrClosed after the request is accepted or dropped, -// and keep the rejection reason after it is rejected. -type GroupDemand struct { - inner *ffi.MoqGroupDemand +// Order is which end to fill first. +func (f *Fill) Order() Order { + return f.inner.Order() +} + +// Next is the next group still owed in the fill's order, or nil once every group is answered. +func (f *Fill) Next() *uint64 { + return f.inner.Next() } -// Sequence is the sequence of the group this watches. -func (d *GroupDemand) Sequence() uint64 { - return d.inner.Sequence() +// Accept inserts group sequence and returns a producer for it. The first group +// starts at StartFrame, so its frames keep their indices. +func (f *Fill) Accept(sequence uint64) (*GroupProducer, error) { + inner, err := f.inner.Accept(sequence) + if err != nil { + return nil, err + } + return &GroupProducer{inner: inner}, nil } -// IsUsed reports whether the group has at least one waiting caller right now. -func (d *GroupDemand) IsUsed() bool { - return d.inner.IsUsed() +// DropGroups names groups start up to the exclusive end as never to be delivered. +func (f *Fill) DropGroups(start, end uint64) { + f.inner.DropGroups(start, end) } -// Used blocks until the group has at least one waiting caller. -func (d *GroupDemand) Used(ctx context.Context) error { - return d.inner.Used(ctx) +// IsUsed reports whether a reader still waits on this fill. +func (f *Fill) IsUsed() bool { + return f.inner.IsUsed() } -// Unused blocks until the group has no waiting callers. -func (d *GroupDemand) Unused(ctx context.Context) error { - return d.inner.Unused(ctx) +// Unused blocks until no reader waits on this fill. +func (f *Fill) Unused(ctx context.Context) error { + return f.inner.Unused(ctx) } diff --git a/go/wrapper/records.go b/go/wrapper/records.go index b6a5e827e71..2d2b96d918e 100644 --- a/go/wrapper/records.go +++ b/go/wrapper/records.go @@ -58,19 +58,20 @@ func datagramFromFFI(d *ffi.MoqDatagram) *Datagram { } // Subscription holds subscriber-side delivery preferences: priority, the max delay -// a non-latest group may fall behind before it is skipped (zero skips at once, and it -// caps every range), the ranges of the track to deliver (nil for everything MaxDelay -// still allows, while an empty non-nil slice asks for nothing), and the order the -// publisher sends pending groups in (nil for newest first). +// a non-latest group may fall behind before it is skipped (zero skips at once, nil +// leaves the publisher's max age alone to bound it, and it caps every range), the +// ranges of the track to deliver (nil for everything MaxDelay still allows, while an +// empty non-nil slice asks for nothing), and the order the publisher sends pending +// groups in (nil for newest first). type Subscription struct { Priority uint8 - MaxDelay time.Duration + MaxDelay *time.Duration Ranges []Range Order *Order } func (s Subscription) ffi() (ffi.MoqSubscription, error) { - maxDelay, err := bridge.Duration(s.MaxDelay, "max delay") + maxDelay, err := bridge.OptionalDuration(s.MaxDelay, "max delay") var ranges *[]ffi.MoqRange if s.Ranges != nil { ranges = &s.Ranges diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 6430cf04fc7..903548140f0 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -77,16 +77,14 @@ typealias BroadcastDynamic = uniffi.moq.MoqBroadcastDynamic typealias TrackProducer = uniffi.moq.MoqTrackProducer /** A subscriber-requested track not yet accepted: accept it for a [TrackProducer] or abort it. */ typealias TrackRequest = uniffi.moq.MoqTrackRequest -/** A stream of uncached group requests for one track, for serving fetches on demand. */ +/** A stream of the fills readers of one track need: runs of groups the cache lacks. */ typealias TrackDynamic = uniffi.moq.MoqTrackDynamic /** A watch-only handle to whether a published track has subscribers; holding it keeps nothing open. */ typealias TrackDemand = uniffi.moq.MoqTrackDemand /** The read side of a raw track: yields groups in sequence order, skipping ahead if it falls behind. */ typealias TrackConsumer = uniffi.moq.MoqTrackConsumer -/** A request to produce one uncached group for a fetch consumer. */ -typealias GroupRequest = uniffi.moq.MoqGroupRequest -/** A watch-only handle to whether any caller still wants a requested group; holding it keeps nothing open. */ -typealias GroupDemand = uniffi.moq.MoqGroupDemand +/** A run of groups readers miss: accept each group you have and drop the rest. */ +typealias Fill = uniffi.moq.MoqFill /** The write side of a single group: append frames to it. */ typealias GroupProducer = uniffi.moq.MoqGroupProducer /** The read side of a single group: yields timestamped raw frames. */ diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt index 6dd59ce6e05..822b58eef95 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt @@ -49,9 +49,13 @@ val QuicConfig.idleTimeout: Duration? val QuicConfig.keepAlive: Duration? get() = keepAliveUs?.toLong()?.microseconds -/** Upper bound on buffering before a stalled group is skipped. */ -val Subscription.maxDelay: Duration - get() = maxDelayUs.toLong().microseconds +/** Upper bound on buffering before a stalled group is skipped, or null for the publisher's max age alone. */ +val Subscription.maxDelay: Duration? + get() = maxDelayUs?.toLong()?.microseconds + +/** The most tolerant age budget among a fill's readers, or null for the publisher's max age alone. */ +val Fill.maxDelay: Duration? + get() = maxDelayUs()?.toLong()?.microseconds /** Maximum age of a non-latest group before the publisher evicts it, or null for the default. */ val TrackInfo.maxAge: Duration? diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt index 5dc5e412567..1ca547500d8 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt @@ -39,8 +39,8 @@ fun TrackConsumer.datagrams(): Flow = pull({ cancel() }) { recvDatagra /** Stream of tracks requested by subscribers. */ fun BroadcastDynamic.requestedTracks(): Flow = pull({ cancel() }) { requestedTrack() } -/** Stream of uncached group requests for one track. */ -fun TrackDynamic.requestedGroups(): Flow = pull({ cancel() }) { requestedGroup() } +/** Stream of the fills readers of one track need. */ +fun TrackDynamic.requestedFills(): Flow = pull({ cancel() }) { requestedFill() } /** Stream of broadcasts requested by consumers. */ fun OriginDynamic.requestedBroadcasts(): Flow = pull({ cancel() }) { requestedBroadcast() } diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index 94050e1c24c..55fa80be81b 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -59,8 +59,7 @@ asyncio.run(main()) TrackDynamic TrackRequest GroupProducer - GroupRequest - GroupDemand + Fill ``` ## Subscribing diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index f564c37f84c..59cb74cba89 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -32,9 +32,8 @@ from .publish import ( BroadcastDynamic, BroadcastProducer, - GroupDemand, + Fill, GroupProducer, - GroupRequest, TrackDemand, TrackDynamic, TrackProducer, @@ -104,9 +103,8 @@ "ProtocolError", "ProtocolKind", "FetchGroupOptions", + "Fill", "GroupConsumer", - "GroupDemand", - "GroupRequest", "GroupProducer", "OriginConsumer", "OriginDynamic", diff --git a/py/moq-rs/moq/_records.py b/py/moq-rs/moq/_records.py index a065d3f7255..5e8783dde6a 100644 --- a/py/moq-rs/moq/_records.py +++ b/py/moq-rs/moq/_records.py @@ -93,20 +93,21 @@ class Subscription: """Subscriber-side delivery preferences, mirroring moq-net's ``track::Subscription``. ``max_delay`` is how far a non-latest group may fall behind before it is skipped; - zero skips at once, and it caps every range. ``ranges`` are the spans of the track to + zero skips at once, ``None`` leaves the publisher's max age alone to bound it, and it + caps every range. ``ranges`` are the spans of the track to deliver, ``None`` for everything ``max_delay`` still allows. ``order`` is which pending group the publisher sends first, ``None`` for newest first. """ priority: int = 0 - max_delay: timedelta = timedelta(0) + max_delay: timedelta | None = timedelta(0) ranges: Sequence[MoqRange] | None = None order: MoqOrder | None = None def _ffi(self) -> MoqSubscription: return MoqSubscription( priority=self.priority, - max_delay_us=_to_us(self.max_delay, "max_delay"), + max_delay_us=_opt_us(self.max_delay, "max_delay"), ranges=None if self.ranges is None else list(self.ranges), order=self.order, ) diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index c041bb2aeb7..ba3a59e994c 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -8,16 +8,16 @@ from moq_ffi import ( MoqBroadcastDynamic, MoqBroadcastProducer, - MoqGroupDemand, + MoqFill, MoqGroupProducer, - MoqGroupRequest, + MoqOrder, MoqTrackDemand, MoqTrackDynamic, MoqTrackProducer, MoqTrackRequest, ) -from ._records import _subscription, _track_info +from ._records import _opt_from_us, _subscription, _track_info from ._stream import _Stream from .types import ( Frame, @@ -190,68 +190,74 @@ def abort(self, error_code: int) -> None: self._inner.abort(error_code) -class GroupDemand: - """A watch-only handle to the callers waiting on a requested group. +class Fill: + """A run of groups readers miss, for a :class:`TrackDynamic` to serve. - Returned by ``GroupRequest.demand()``. Weak: holding it does not keep the - request alive. The last caller to leave withdraws the request, so once - unused, demand never returns: drop the request. Waits raise - ``moq.Error.Closed`` after the request is accepted or dropped, and keep - the rejection reason after it is rejected. + Call :meth:`accept` for each group you have and :meth:`drop_groups` for the rest; + :meth:`next` names what is still owed, in the fill's order. Dropping the fill names + whatever is left as dropped while a reader still wants it; once nobody does, it is + withdrawn, so stop serving it when :meth:`unused` returns. """ - def __init__(self, inner: MoqGroupDemand) -> None: + def __init__(self, inner: MoqFill) -> None: self._inner = inner @property - def sequence(self) -> int: - """The sequence of the group this watches.""" - return self._inner.sequence() + def start_group(self) -> int: + """The first group to fill.""" + return self._inner.start_group() - def is_used(self) -> bool: - """Whether the group has at least one waiting caller right now.""" - return self._inner.is_used() + @property + def start_frame(self) -> int: + """The frame within the first group to start from.""" + return self._inner.start_frame() - async def used(self) -> None: - """Wait until the group has at least one waiting caller.""" - await self._inner.used() + @property + def end_group(self) -> int: + """The exclusive end group.""" + return self._inner.end_group() - async def unused(self) -> None: - """Wait until the group has no waiting callers.""" - await self._inner.unused() + @property + def priority(self) -> int: + """The most urgent delivery priority among the readers waiting on it.""" + return self._inner.priority() + @property + def max_delay(self) -> timedelta | None: + """The most tolerant age budget among its readers, ``None`` for the publisher's max age alone.""" + return _opt_from_us(self._inner.max_delay_us()) -class GroupRequest: - """A request to produce one uncached group for a fetch consumer.""" + @property + def order(self) -> MoqOrder: + """Which end to fill first.""" + return self._inner.order() - def __init__(self, inner: MoqGroupRequest) -> None: - self._inner = inner + def next(self) -> int | None: + """The next group still owed in the fill's order, ``None`` once every group is answered.""" + return self._inner.next() - @property - def sequence(self) -> int: - """The requested group sequence within the track.""" - return self._inner.sequence() + def accept(self, sequence: int) -> GroupProducer: + """Insert group ``sequence`` and return a producer for it. - @property - def priority(self) -> int: - """The consumer's delivery priority for this fetch.""" - return self._inner.priority() + The first group starts at :attr:`start_frame`, so its frames keep their indices. + """ + return GroupProducer(self._inner.accept(sequence)) - def demand(self) -> GroupDemand: - """A watch-only handle to whether any caller still wants this group.""" - return GroupDemand(self._inner.demand()) + def drop_groups(self, start: int, end: int) -> None: + """Name groups ``start`` up to the exclusive ``end`` as never to be delivered.""" + self._inner.drop_groups(start, end) - def accept(self) -> GroupProducer: - """Accept the request and return a producer for the group.""" - return GroupProducer(self._inner.accept()) + def is_used(self) -> bool: + """Whether a reader still waits on this fill.""" + return self._inner.is_used() - def abort(self, error_code: int) -> None: - """Reject the fetch with an application error code.""" - self._inner.abort(error_code) + async def unused(self) -> None: + """Wait until no reader waits on this fill.""" + await self._inner.unused() -class TrackDynamic(_Stream[GroupRequest]): - """Async source of uncached group requests for one track. +class TrackDynamic(_Stream[Fill]): + """Async source of the fills readers of one track need: runs of groups the cache lacks. Usable as an async context manager that cancels on exit. """ @@ -259,15 +265,15 @@ class TrackDynamic(_Stream[GroupRequest]): def __init__(self, inner: MoqTrackDynamic) -> None: self._inner = inner - async def _next(self) -> GroupRequest: - return await self.requested_group() + async def _next(self) -> Fill: + return await self.requested_fill() - async def requested_group(self) -> GroupRequest: - """Wait for the next uncached group request.""" - return GroupRequest(await self._inner.requested_group()) + async def requested_fill(self) -> Fill: + """Wait for the next fill.""" + return Fill(await self._inner.requested_fill()) def cancel(self) -> None: - """Cancel current and future group request waits.""" + """Cancel current and future fill waits.""" self._inner.cancel() diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 38621566fa3..7dabda83b1f 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -106,25 +106,20 @@ def test_origin_lifecycle(): _consumer = origin.consume() -async def test_fetch_abort_is_a_stream_app_code(): +async def test_fetch_of_a_dropped_group_is_not_found(): broadcast = moq.BroadcastProducer() track = broadcast.publish_track("events") dynamic = track.dynamic() consumer = broadcast.consume() async def reject(): - request = await dynamic.requested_group() - request.abort(404) + fill = await dynamic.requested_fill() + fill.drop_groups(5, 6) task = asyncio.create_task(reject()) - with pytest.raises(moq.Error.Protocol) as raised: # type: ignore[attr-defined] + with pytest.raises(moq.Error.NotFound): # type: ignore[attr-defined] await consumer.fetch_group("events", 5) await task - protocol = moq.protocol_error(raised.value) - assert protocol is not None - assert protocol.scope == moq.ErrorScope.STREAM - assert protocol.code == 64 + 404 - assert protocol.kind == moq.ProtocolKind.APP def test_protocol_error_helper_covers_known_app_and_unknown(): @@ -471,14 +466,13 @@ async def test_fetch_group_and_serve_dynamic_miss(): dynamic = track.dynamic() pending = asyncio.create_task(consumer.fetch_group("events", 7, moq.FetchGroupOptions(priority=11))) - request = await asyncio.wait_for(dynamic.requested_group(), timeout=5.0) - assert request.sequence == 7 - assert request.priority == 11 - demand = request.demand() - assert demand.sequence == 7 - assert demand.is_used() + fill = await asyncio.wait_for(dynamic.requested_fill(), timeout=5.0) + assert (fill.start_group, fill.start_frame, fill.end_group) == (7, 0, 8) + assert fill.priority == 11 + assert fill.next() == 7 + assert fill.is_used() - produced = request.accept() + produced = fill.accept(7) produced.write_frame(b"archive", timedelta(microseconds=140_000)) produced.finish() @@ -1555,7 +1549,7 @@ async def assert_cancelled(awaitable) -> None: track = broadcast.publish_track("events") async with track.dynamic() as track_dynamic: pass - await assert_cancelled(track_dynamic.requested_group()) + await assert_cancelled(track_dynamic.requested_fill()) snapshot = moq.json.SnapshotProducer(broadcast, broadcast.publish_track("state")) async with moq.json.SnapshotConsumer(await broadcast.consume().subscribe_track("state")) as snapshot_consumer: diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 1fd646fdd62..ac963bbcf8e 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -34,7 +34,7 @@ pub struct MoqSubscription { /// /// Enforced both by the publisher's cache (sent on the wire) and by any local /// buffering, such as [`MoqMediaTrackConsumer`]'s jitter buffer. It caps every range. - #[uniffi(default = 0)] + #[uniffi(default = Some(0))] pub max_delay_us: Option, /// The spans of the track to deliver, or null for everything `max_delay_us` still /// considers fresh. An empty list asks for nothing. diff --git a/swift/Sources/Moq/Dynamic.swift b/swift/Sources/Moq/Dynamic.swift index 8dc8c5539f9..964147375f4 100644 --- a/swift/Sources/Moq/Dynamic.swift +++ b/swift/Sources/Moq/Dynamic.swift @@ -37,78 +37,80 @@ public final class TrackRequest: Sendable { } } -/// A watch-only handle to the callers waiting on a requested group. +/// A run of groups readers miss, for a `TrackDynamic` to serve. /// -/// Returned by `GroupRequest.demand()`. Weak: holding it does not keep the request alive. The last -/// caller to leave withdraws the request, so once unused, demand never returns: drop the request. -/// Waits throw `MoqError.Closed` after the request is accepted or dropped, and keep the rejection -/// reason after it is rejected. -public final class GroupDemand: Sendable { - let ffi: MoqGroupDemand - - init(_ ffi: MoqGroupDemand) { +/// Accept each group you have and drop the rest; `next()` names what is still owed, in the +/// fill's order. Releasing the fill names whatever is left as dropped while a reader still +/// wants it; once nobody does, it is withdrawn, so stop serving it when `unused()` returns. +public final class Fill: Sendable { + let ffi: MoqFill + + init(_ ffi: MoqFill) { self.ffi = ffi } - /// The sequence of the group this watches. - public var sequence: UInt64 { - ffi.sequence() + /// The first group to fill. + public var startGroup: UInt64 { + ffi.startGroup() } - /// Whether the group has at least one waiting caller right now. - public var isUsed: Bool { - ffi.isUsed() + /// The frame within the first group to start from. + public var startFrame: UInt64 { + ffi.startFrame() } - /// Suspend until the group has at least one waiting caller. - public func used() async throws { - try await ffi.used() + /// The exclusive end group. + public var endGroup: UInt64 { + ffi.endGroup() } - /// Suspend until the group has no waiting callers. - public func unused() async throws { - try await ffi.unused() + /// The most urgent delivery priority among the readers waiting on it. + public var priority: UInt8 { + ffi.priority() } -} -/// A request to produce one uncached group for a fetch consumer. -public final class GroupRequest: Sendable { - let ffi: MoqGroupRequest + /// The most tolerant age budget among its readers in microseconds, or nil for the + /// publisher's max age alone. + public var maxDelayUs: UInt64? { + ffi.maxDelayUs() + } - init(_ ffi: MoqGroupRequest) { - self.ffi = ffi + /// Which end to fill first. + public var order: Subscription.Order { + ffi.order() } - /// The requested group sequence within the track. - public var sequence: UInt64 { - ffi.sequence() + /// The next group still owed in the fill's order, or nil once every group is answered. + public func next() -> UInt64? { + ffi.next() } - /// The consumer's delivery priority for this fetch. - public var priority: UInt8 { - ffi.priority() + /// Insert group `sequence` and return a producer for it. The first group starts at + /// `startFrame`, so its frames keep their indices. + public func accept(sequence: UInt64) throws -> GroupProducer { + GroupProducer(try ffi.accept(sequence: sequence)) } - /// A watch-only handle to whether any caller still wants this group. - public func demand() throws -> GroupDemand { - GroupDemand(try ffi.demand()) + /// Name groups `start` up to the exclusive `end` as never to be delivered. + public func dropGroups(start: UInt64, end: UInt64) { + ffi.dropGroups(start: start, end: end) } - /// Accept the request and return a producer for the group. - public func accept() throws -> GroupProducer { - GroupProducer(try ffi.accept()) + /// Whether a reader still waits on this fill. + public var isUsed: Bool { + ffi.isUsed() } - /// Reject the fetch with an application error code. - public func abort(errorCode: UInt16) throws { - try ffi.abort(errorCode: errorCode) + /// Suspend until no reader waits on this fill. + public func unused() async throws { + try await ffi.unused() } } -/// A stream of uncached group requests for one track. +/// A stream of the fills readers of one track need: runs of groups the cache lacks. public final class TrackDynamic: AsyncSequence, Sendable { - /// The group request emitted by this sequence. - public typealias Element = GroupRequest + /// The fill emitted by this sequence. + public typealias Element = Fill let ffi: MoqTrackDynamic @@ -116,20 +118,20 @@ public final class TrackDynamic: AsyncSequence, Sendable { self.ffi = ffi } - /// Wait for the next uncached group request. - public func requestedGroup() async throws -> GroupRequest { - GroupRequest(try await ffi.requestedGroup()) + /// Wait for the next fill. + public func requestedFill() async throws -> Fill { + Fill(try await ffi.requestedFill()) } - /// Cancel all current and future group request waits. + /// Cancel all current and future fill waits. public func cancel() { ffi.cancel() } /// Create an iterator that cancels native waits when iteration ends. - public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { moqStream(cancel: { [ffi] in ffi.cancel() }) { [ffi] in - GroupRequest(try await ffi.requestedGroup()) + Fill(try await ffi.requestedFill()) }.makeAsyncIterator() } } From 823aca08ad9924885cc69f2886bf0b0547f3ae35 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 23:07:49 -0700 Subject: [PATCH 07/48] wip: migration notes, docs, and quest references --- dart/moq/MIGRATE.md | 7 ++ doc/concept/moq-lite.md | 7 +- go/wrapper/MIGRATE.md | 11 +++ kt/moq/MIGRATE.md | 7 ++ py/moq-rs/MIGRATE.md | 7 ++ quest/m1/archive/consumer.md | 8 +- quest/m1/public-model/README.md | 4 +- quest/m1/public-model/deferred.md | 2 +- quest/m1/resolved-demand.md | 4 - quest/m1/subscribe-ranges/README.md | 6 +- quest/m1/subscribe-ranges/dynamic.md | 95 --------------------- quest/m1/subscribe-ranges/fetch-range.md | 5 +- quest/m1/subscribe-ranges/frame-bounds.md | 4 - quest/m1/subscribe-ranges/ietf.md | 4 - quest/m1/subscribe-ranges/js.md | 2 +- quest/m1/subscribe-ranges/lite.md | 3 - quest/m2/unbounded-groups/README.md | 4 +- quest/m2/unbounded-groups/front-eviction.md | 4 +- rs/moq-c/MIGRATE.md | 6 ++ rs/moq-ffi/MIGRATE.md | 11 +++ rs/moq-net/MIGRATE.md | 14 +++ swift/MIGRATE.md | 7 ++ 22 files changed, 89 insertions(+), 133 deletions(-) delete mode 100644 quest/m1/subscribe-ranges/dynamic.md diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index b280596e611..fcf0172812d 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup()`, `startFrame()`, `endGroup()`, `priority()`, `maxDelayUs()`, `order()`, `next()`, `accept(sequence)`, `dropGroups(start, end)`, `isUsed()`, `unused()`). +`GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed()` or `unused()` on the fill. +A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. +`Subscription.maxDelayUs` and `maxDelay` are nullable, where null leaves the publisher's max age alone to bound delivery. + ### Anchors can name a time (#6074) `Anchor` gains a time variant, so exhaustive switches need a case for it. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index d7a9f893ca2..6b5aa382b69 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -237,7 +237,7 @@ Each subscription carries the knobs that decide behavior under congestion: | --- | --- | | **Priority** (0..255) | Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement. From a native Rust publisher over QUIC or WebTransport (iroh excepted), subscriptions at the same priority split bandwidth evenly by bytes, streams and datagrams alike, however many groups each has queued. A browser publisher and other transports without send groups are not covered. | | **Order** | Which group to send first when several are pending. Newest first for live (the default), oldest first for catch-up. Needs moq-lite 07 or moq-transport; older moq-lite always sends newest first. | -| **Max delay** | How long a non-latest group stays worth delivering before it is skipped. Zero means "live edge only", and raising it is also what asks for history. | +| **Max delay** | How long a non-latest group stays worth delivering before it is skipped. Zero means "live edge only", and raising it is also what asks for history. Unset leaves the publisher's retention window alone to bound it. | | **Ranges** | Which groups to ask for: from a group, a number of groups back from the latest, or the group containing a presentation time, optionally up to an end. The default asks for everything max delay allows. Max delay still caps every range. | On moq-lite 05 and later, a publisher explicitly reports groups that will not arrive. @@ -248,7 +248,10 @@ Ranges are both a request and a filter. A subscriber reads only the groups its own ranges cover, resolved again whenever it replaces them, even when another subscriber of the same track asked for more. Widening them, including lowering the start, also serves the cached groups they newly cover, never one already -served. A relay merges its subscribers' ranges into one upstream request, which +served. A group they cover below the live feed that the cache lacks (evicted, +skipped, or below where the feed starts) is asked of the track's dynamic handler +as a fill, which a relay serves by fetching upstream one group at a time; with +no handler it is reported as dropped once the feed stops waiting for it. A relay merges its subscribers' ranges into one upstream request, which today's wire versions carry as the single span covering them. A time anchor is resolved from the groups the serving hop holds; upstream it asks for everything max delay allows, and an untimed track refuses it. At the live edge, a time end diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index b5bfc663b1d..90843c4fab9 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,6 +4,17 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic.RequestedGroup` is now `RequestedFill`, returning a `*Fill` (`StartGroup`, `StartFrame`, `EndGroup`, `Priority`, `MaxDelay`, `Order`, `Next`, `Accept(sequence)`, `DropGroups(start, end)`, `IsUsed`, `Unused`), and `All` ranges over fills. +`GroupRequest` and `GroupDemand` are removed: serve each group `Next` names, and watch `IsUsed` or `Unused` on the fill. +A refusal carries no application code: `DropGroups` fails the waiting fetches with `ErrNotFound`. + +### `Subscription.MaxDelay` is a pointer + +`Subscription.MaxDelay` is a `*time.Duration`, where nil leaves the publisher's max age alone to bound delivery. +A zero-value `Subscription` now means that rather than live edge only: point `MaxDelay` at a zero duration to keep skipping at once. + ### Anchors can name a time (#6074) `AnchorTime` names the group containing a presentation time, and type switches over `Anchor` need a case for it. diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 9a3ad22f29e..34361b421b7 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup()`, `startFrame()`, `endGroup()`, `priority()`, `maxDelayUs()`, `order()`, `next()`, `accept(sequence)`, `dropGroups(start, end)`, `isUsed()`, `unused()`), and `requestedGroups()` is now `requestedFills()`. +`GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed()` or `unused()` on the fill. +A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. +`Subscription.maxDelayUs` is a `ULong?` and `maxDelay` a `Duration?`, where null leaves the publisher's max age alone to bound delivery. + ### Anchors can name a time (#6074) `Anchor` gains `AnchorTime`, so exhaustive `when` expressions need a branch for it. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 59f06c9963e..0c4f0be917f 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic.requested_group()` is now `requested_fill()`, returning a `Fill` (`start_group`, `start_frame`, `end_group`, `priority`, `max_delay`, `order`, `next()`, `accept(sequence)`, `drop_groups(start, end)`, `is_used()`, `unused()`), and iterating a `TrackDynamic` yields fills. +`GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `is_used()` or `unused()` on the fill. +A refusal carries no application code: `drop_groups` fails the waiting fetches with `moq.Error.NotFound`. +`Subscription.max_delay` also accepts `None`, leaving the publisher's max age alone to bound delivery. + ### Anchors can name a time (#6074) `Anchor` gains a `TIME` variant, the group containing a presentation time. diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md index a71663eee6f..17266f1ae05 100644 --- a/quest/m1/archive/consumer.md +++ b/quest/m1/archive/consumer.md @@ -60,10 +60,10 @@ Guidance: the same way; history-mode callers rely on the start answering with what remains ([History from the retained floor](/quest/m1/archive/history-floor.md)). - Keep a byte-bounded object LRU so neighbouring groups come from one GET. -- When [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) lands, the - `track::Dynamic` handler resolves a `Time(pts)` end by picking the object - from the key listing, then the group containing pts from that object's - group timestamps, whichever quest lands second. +- A `track::Fill` names group positions only, so a `Time(pts)` end the cache + can't resolve never reaches the handler. Resolving one from the key listing + (the object, then the group containing pts from its group timestamps) needs + a fill that carries the anchor. - Move `rs/moq-hls/src/export/archive_tests.rs` onto a consumer broadcast with its archived catalog, deleting the hand-built catalog. - Update `doc/bin/cli.md` and `doc/bin/hls.md` inline with an export, import, diff --git a/quest/m1/public-model/README.md b/quest/m1/public-model/README.md index 3f5981bacde..7c7dc0808c2 100644 --- a/quest/m1/public-model/README.md +++ b/quest/m1/public-model/README.md @@ -43,8 +43,8 @@ Rules every child follows: the mark as the item goes public; the lint fails on a mark left behind, or on one naming a deleted quest. - Items replaced by another quest's new API wait for that quest instead of - growing a public shape it deletes: fetch and per-group requests - ([Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md)), announce + growing a public shape it deletes: fetch + ([Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md)), announce interest ([Rust lazy discovery](/quest/m1/lazy-discovery/rust.md)). Inventory method, to rerun before each child starts (counts drift). Rust: diff --git a/quest/m1/public-model/deferred.md b/quest/m1/public-model/deferred.md index 87c752e6700..6360ba01bba 100644 --- a/quest/m1/public-model/deferred.md +++ b/quest/m1/public-model/deferred.md @@ -45,7 +45,7 @@ built-in sessions moved onto the public calls. ## Required -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - replaces per-group fetch requests, the peeks, and `fork` +- [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - replaces per-group fetches, the peeks, and `fork` - [Public ingest](/quest/m1/public-model/ingest.md) - settles the received-end shape JS mirrors - [JS ranges](/quest/m1/subscribe-ranges/js.md) - the JS `Track.Request`/`Track.Dynamic` range requests that answer track info and fetches - [Public routes and answering](/quest/m1/public-model/answer.md) - the public `Broadcast.requested` handout this completes diff --git a/quest/m1/resolved-demand.md b/quest/m1/resolved-demand.md index a00ee625d33..783b119d524 100644 --- a/quest/m1/resolved-demand.md +++ b/quest/m1/resolved-demand.md @@ -168,10 +168,6 @@ Public API: none. Wire: no encoding change. A publisher now re-offers cached groups it never reported dropped when a SUBSCRIBE_UPDATE widens the max age or the range. -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - #5929 lands the re-offer and serving path and the range miss fill this reuses - ## Related - [Archive producer](/quest/m1/archive/producer.md) - its resume floor crosses relays that viewers may have warmed diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index b6399fc948e..9b3b0cf85fe 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -34,9 +34,8 @@ relay bounds a sparse FETCH only if the model can request ranges: publisher delivers what it has and fills the rest as upstream answers. - **Model.** `Subscription` gains `ranges` beside its `order`, mirroring the wire; `request_groups` as a separate API was rejected. A publisher's - `track::Dynamic` receives range requests for misses, replacing per-group - `requested_group`, and `fetch_group` becomes a one-range subscription or is - removed. This breaks published moq-net APIs. + `track::Dynamic` receives a `track::Fill` per run of misses (landed), and + `fetch_group` becomes a one-range subscription or is removed. This breaks published moq-net APIs. - **moq-transport.** Some deployments use non-contiguous group numbers, so a relay never probes upstream one group at a time. Upstream, each run of locally missing groups becomes one standalone range FETCH, capped below the @@ -95,7 +94,6 @@ every sequence delivered or dropped. ## Required -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `Dynamic` fills misses by range, replacing `requested_group` - [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - `fetch_group` becomes a one-range subscription that ends once its range is accounted for - [Lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) - the lite-07 wire, Rust publisher and subscriber, and the draft - [moq-transport ranges](/quest/m1/subscribe-ranges/ietf.md) - range FETCH upstream per missing run, and non-blocking FETCH served downstream diff --git a/quest/m1/subscribe-ranges/dynamic.md b/quest/m1/subscribe-ranges/dynamic.md deleted file mode 100644 index 2a96fd1bb92..00000000000 --- a/quest/m1/subscribe-ranges/dynamic.md +++ /dev/null @@ -1,95 +0,0 @@ -# [L] Dynamic range requests - -## Goal - -A publisher's `track::Dynamic` receives range requests for misses instead of -one request per group, and every sequence in a subscription's range either -arrives or is named as dropped, so a reader never waits on a miss. -`requested_group` is replaced by `requested_fill`, not kept beside it, in every -caller and binding (the frozen moq-c ABI excepted, per the moq-c decision -below). Removing `fetch_group` is the follow-up -[fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md). - -## Plan - -A subscriber already reads only its own ranges, re-resolved when they are -replaced. What remains is the miss: one below the cache becomes a range request -on `Dynamic` rather than a group request. A time anchor the cache can't -resolve passes to the handler unresolved in its range request. - -- Replace `requested_group` rather than keep both. Update every caller - (sessions, relay, moq-archive, hls, the ladder) in the same PR, including - the archive consumer's time resolution from its key listing if it has - landed, plus the published surfaces built on it, per the cross-package - table: moq-ffi (`requested_group`), every binding wrapper and its `doc/lib` - page. Until the follow-up lands, a `fetch_group` miss queues a one-group - fill. -- Every sequence in range either arrives or is named as dropped. Share the - accounting with SUBSCRIBE_DROP rather than adding a second one: the cursor's - drop scan already names only sequences inside the reader's own ranges. -- Decided in the 2026-10-10 audit: `max_delay` caps every range, including - historical FETCH replacements, and publisher `max_age` always clamps it. - A FETCH with no reader delay uses publisher max-age alone, not the live - SUBSCRIBE zero-delay default. Sharing a range never relaxes a caller's - budget or lets one expired caller cancel another still-valid reader. - -Decided by the maintainer on 2026-10-20, when the quest was first started -([moq-dev/moq#6123](https://github.com/moq-dev/moq/pull/6123)): - -- **Split.** This quest replaces `requested_group`; removing `fetch_group` - is [fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md), so each - PR breaks one API once. That half needs head fills, which - [frame bounds](/quest/m1/subscribe-ranges/frame-bounds.md) provides. -- **Handler shape.** `Dynamic::requested_fill` yields a `track::Fill` naming - one bounded run: `start: Position`, exclusive end group, priority, the most - tolerant budget among its readers, and order. Each reader's cursor still - skips what is stale under its own `max_delay`, so sharing never relaxes it. - The handler calls `accept(sequence, info)` for each group it has and - `drop_groups(range)` for the rest. Dropping the `Fill` - names whatever is left as dropped, in the track's existing `drops`, but only - while a reader still wants it: once demand leaves, the fill is withdrawn like - a `group::Request` today and records nothing, so `drops` (durable) never - hides still-fetchable history from a later reader. - Rejected: writing through `Producer::create_group`, since a fetch-only copy - or an archive track has no live producer, and backfill must stay out of the - arrival walk. -- **What a miss is.** A sequence in a reader's window, below its cap, that - the cache doesn't cover and the feed will never deliver: below the feed's - start, or at or below its newest group and not awaited by the arrival - ledger (evicted or skipped). Nothing above that frontier is a miss, since it - arrives live, so a fill is bounded by construction. A relay records the - span a SUBSCRIBE_START skipped past its requested floor as dropped (today it - only settles the tail), or it would FETCH what upstream already gave up. -- **Fetch budget.** `Subscription::max_delay` becomes `Option`: - `None` means publisher max-age alone, the FETCH default above. This breaks - `Subscription` in every binding, with a `MIGRATE.md` entry in each affected - package. Rejected (recommended, but overruled): a `Duration::MAX` sentinel - that the wire saturates, which avoids the break but encodes the meaning in a - magic value. -- **Sessions.** A session serves a fill as it serves a group request today: - one FETCH per group, one in flight per fill, newest first for a descending - reader, withdrawing (not dropping) the fill when demand leaves. Once a - fetched group is stale under the fill's budget, the groups older than it - are too (a timed track's starts never rewind), so the fill stops fetching - them; in either order, newer groups still arrive. They are withdrawn, not - dropped, and each reader's cursor names them stale under its own budget, - since a later reader with a wider one may still fetch them. Only staleness - under publisher max-age, which binds every reader, goes in `drops`. - [Lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) turns lite-07 - fills into range subscriptions, and - [moq-transport ranges](/quest/m1/subscribe-ranges/ietf.md) into one FETCH - per run. Until then, one SUBSCRIBE range can cost up to frontier minus floor - serialized upstream FETCHes, each bounded by the fill's budget and stopped - when demand leaves, the same cost per-group requests carry today; accepted - rather than blocking on those quests. -- **moq-c.** Whichever lands first decides it. If the generated C package - ([moq-dev/moq#6052](https://github.com/moq-dev/moq/pull/6052)) has landed, - `poll_requested_group` leaves the hand-written ABI with a `MIGRATE.md` entry - and the fill reaches C through the generated API. Otherwise the frozen crate - splits each fill into its per-group requests internally, keeping its ABI - unchanged, and maps `moq_subscription.max_delay_us` to `Some`: a frozen C - subscriber cannot ask for max-age alone, as today. Not a blocker. - -## Related - -- [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - removes `fetch_group` once fills land diff --git a/quest/m1/subscribe-ranges/fetch-range.md b/quest/m1/subscribe-ranges/fetch-range.md index f1caf9f25f7..9b1eff1871e 100644 --- a/quest/m1/subscribe-ranges/fetch-range.md +++ b/quest/m1/subscribe-ranges/fetch-range.md @@ -10,8 +10,8 @@ filling over range count and span as separate axes. ## Plan -Split out of [dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) -by the maintainer on 2026-10-20 +Split out of dynamic range requests, which replaced `requested_group` with +`requested_fill`, by the maintainer on 2026-10-20 ([moq-dev/moq#6123](https://github.com/moq-dev/moq/pull/6123)), so each PR breaks one API once. @@ -34,5 +34,4 @@ breaks one API once. ## Required -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `requested_fill` and subscriptions filling their misses - [Frame bounds](/quest/m1/subscribe-ranges/frame-bounds.md) - head fills for a range starting mid-group diff --git a/quest/m1/subscribe-ranges/frame-bounds.md b/quest/m1/subscribe-ranges/frame-bounds.md index 23c940af714..527e97b7413 100644 --- a/quest/m1/subscribe-ranges/frame-bounds.md +++ b/quest/m1/subscribe-ranges/frame-bounds.md @@ -31,7 +31,3 @@ shape in `js/net/src/lite/publisher.ts`). Regression test: a start at frame 5 and an end at frame 3 of a cached group, each read through `recv_group` and `ordered()`. - -## Related - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - the per-subscriber ranges this extends to frames diff --git a/quest/m1/subscribe-ranges/ietf.md b/quest/m1/subscribe-ranges/ietf.md index 2ad5580291d..5f374123b7e 100644 --- a/quest/m1/subscribe-ranges/ietf.md +++ b/quest/m1/subscribe-ranges/ietf.md @@ -48,10 +48,6 @@ moq-transport has no time filter, so a downstream `Time(pts)` end is resolved against the relay's cache before it goes upstream as a group range, or refused when the cache cannot resolve it. -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - the range requests this answers - ## Related - [Pipelined first FETCH](/quest/m2/pipeline-requests/fetch.md) - sends TRACK_STATUS alongside the FETCH in adjacent code (`ietf/subscriber.rs`, `model/origin.rs`); an independent m2 optimization that adapts to these ranges and does not gate them diff --git a/quest/m1/subscribe-ranges/js.md b/quest/m1/subscribe-ranges/js.md index 831ab0ee0c1..6bf3cfa9ec5 100644 --- a/quest/m1/subscribe-ranges/js.md +++ b/quest/m1/subscribe-ranges/js.md @@ -14,7 +14,7 @@ Mirror the Rust names, including `Time(pts)` ends and their refusal on an untime Decided in the 2026-10-05 audit: the producer half of [JavaScript FETCH](/quest/m1/js-fetch.md) folds in here, so `@moq/net`'s on-demand request surface takes range requests from the start instead of the -per-group `requested_group` shape Rust is replacing. That quest keeps only +per-group `requested_group` shape Rust replaced with `requested_fill`. That quest keeps only IETF FETCH dispatch. Resized from [M] to [L] for it. - Expose an owned request handle rather than a storage callback; dropping a diff --git a/quest/m1/subscribe-ranges/lite.md b/quest/m1/subscribe-ranges/lite.md index ea10d101460..664cd0bb4d1 100644 --- a/quest/m1/subscribe-ranges/lite.md +++ b/quest/m1/subscribe-ranges/lite.md @@ -53,6 +53,3 @@ the priority table in `doc/concept/moq-lite.md`: it now describes the field FETCH, answered through the model's ranges. Run `just drafts check` and `just test interop --all`. -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - the misses a relay fills upstream by range diff --git a/quest/m2/unbounded-groups/README.md b/quest/m2/unbounded-groups/README.md index b6fe76b0422..4dceddab351 100644 --- a/quest/m2/unbounded-groups/README.md +++ b/quest/m2/unbounded-groups/README.md @@ -65,9 +65,7 @@ Decided 2026-10-16 (maintainer interview): the relay cache docs, the json/flate docs, and the e2ee draft's 32 MiB interop cap. There is no new page. -The refill API is the -[Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) -`track::Dynamic` range request. A handler serves a range by writing into a +The refill API is the `track::Dynamic` fill (`track::Fill`). A handler serves a range by writing into a group producer bounded to that range, the same API as a live write, so an origin can regenerate data from disk or an archive. diff --git a/quest/m2/unbounded-groups/front-eviction.md b/quest/m2/unbounded-groups/front-eviction.md index 260c33b889c..8f55bffba9e 100644 --- a/quest/m2/unbounded-groups/front-eviction.md +++ b/quest/m2/unbounded-groups/front-eviction.md @@ -30,8 +30,7 @@ See the [epic](/quest/m2/unbounded-groups/README.md) for the decisions. takes it: hand its first demanded frame to that reader, or backpressure the refill on the reader's cursor. Test a `track::Dynamic` handler that writes more than the pool synchronously, so the reader still progresses. -- Refill goes through - [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md). Over +- Refill goes through a `track::Dynamic` fill. Over moq-transport, a hole that covers whole objects refills; one inside an object is `Evicted`. - Downstream encoders that roll groups by the removed constants (moq-json, @@ -47,4 +46,3 @@ groups. A reader that used to see `GroupTooLarge` now refills or sees ## Required - [Segmented frames](/quest/m2/unbounded-groups/segmented-frames.md) - frame storage the cache can account for without whole-frame preallocation -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `track::Dynamic` receives frame-precise range requests for misses diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 6ce1689b31f..817fe496753 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 +### Group requests come from fills + +The ABI is unchanged. A track's dynamic handler now also gets group requests for history a subscription covers that the cache lacks, not only for fetches; each run arrives one group at a time, the next once the previous is accepted, aborted, or freed. +`moq_group_request_abort` no longer delivers its error code: the waiting fetches fail as not found. +`moq_subscription.max_delay_us` always sets a budget, so a C subscriber cannot ask for the publisher's max age alone. + ### Anchors can name a time (#6074) `moq_anchor` gains `timestamp_us`, read with the new `MOQ_ANCHOR_TIME` kind, which changes its ABI. diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index eca047ebf96..dfd67282270 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,17 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### Dynamic tracks serve fills + +`MoqTrackDynamic::requested_group` is now `requested_fill`, returning a `MoqFill`: `start_group`, `start_frame`, `end_group`, `priority`, `max_delay_us`, `order`, `next`, `accept(sequence)`, `drop_groups(start, end)`, `is_used`, and `unused`. +`MoqGroupRequest` and `MoqGroupDemand` are removed: serve each group `next()` names, and watch `is_used` or `unused` on the fill. +A refusal carries no application code: `drop_groups` fails the waiting fetches with `NotFound`. +A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. + +### `MoqSubscription::max_delay_us` is optional + +`max_delay_us` is now nullable, where null leaves the publisher's max age alone to bound delivery. It still defaults to 0. + ### Anchors can name a time (#6074) `MoqAnchor` gains `Time { timestamp_us }`, the group containing that presentation time, so exhaustive matches need an arm for it. diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 8009db2c319..40e168c0218 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,20 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Dynamic` serves fills, not group requests + +`track::Dynamic::requested_group` and `poll_requested_group` are replaced by `requested_fill` and `poll_requested_fill`, which yield a `track::Fill`: a run from `start()` (group and frame) to the exclusive `end()`, with `priority()`, `max_delay()`, and `order()`. +Call `accept(sequence, info)` for each group you have (the first starts at `start().frame`) and `drop_groups(range)` for the rest; `next()` names the next group still owed, in the fill's order. +`group::Request` is removed, and its `demand()` with it: watch the fill's `is_used()` and `unused()` instead. +A fill also arrives for history a subscriber's ranges cover below the live feed that the cache lacks, not only for `fetch_group`. +Dropping a fill with groups unanswered names them dropped, where the live feed won't deliver them, while a reader still waits; a fill nobody waits on is withdrawn and records nothing. + +### `Subscription::max_delay` is optional + +`Subscription::max_delay` is an `Option`, where `None` leaves the publisher's max age alone to bound delivery. +The default stays `Some(Duration::ZERO)`, and `with_max_delay` takes `impl Into>`, so `with_max_delay(delay)` still compiles; reads and assignments of the field need the `Option`. +A lite SUBSCRIBE carries `None` as the largest delay the wire allows. + ### Anchors can name a time (#6074) `track::Anchor` gains `Time(Timestamp)`, the group containing a presentation time, so exhaustive matches need an arm for it. diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index 2f6ffc91e7d..f71fccd4481 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup`, `startFrame`, `endGroup`, `priority`, `maxDelayUs`, `order`, `next()`, `accept(sequence:)`, `dropGroups(start:end:)`, `isUsed`, `unused()`), and iterating a `TrackDynamic` yields fills. +`GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed` or `unused()` on the fill. +A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. +`Subscription.maxDelayUs` is a `UInt64?`, where nil leaves the publisher's max age alone to bound delivery. + ### Anchors can name a time (#6074) `Subscription.Anchor` gains `.time(timestampUs:)`, so exhaustive switches need a case for it. From 93910359c1d07a7b7324abebaa4e34585435dab2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 23:56:47 -0700 Subject: [PATCH 08/48] wip: finished copies serve fills; misses end with the feed; lints Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/test.rs | 5 +- rs/moq-hls/src/export/archive_tests.rs | 7 +- rs/moq-net/src/ietf/publisher.rs | 5 +- rs/moq-net/src/ietf/subscriber.rs | 58 +++++++----- rs/moq-net/src/lite/publisher.rs | 5 +- rs/moq-net/src/lite/subscriber.rs | 20 +++-- rs/moq-net/src/model/resume.rs | 5 +- rs/moq-net/src/model/subscription.rs | 1 + rs/moq-net/src/model/track.rs | 88 +++++++++++++++---- rs/moq-net/src/model/track/fill.rs | 17 +++- rs/moq-net/src/model/track/miss.rs | 34 ++++--- rs/moq-net/tests/integration/fetch_cancel.rs | 9 +- rs/moq-net/tests/integration/fetch_caps.rs | 5 +- .../subscriber-max-age-ignored.patch | 2 +- 14 files changed, 183 insertions(+), 78 deletions(-) diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 0931a3f7f68..9bcbd5593b0 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -1994,7 +1994,10 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { .await .expect("timed out waiting for fill") .unwrap(); - assert_eq!((request.start_group(), request.start_frame(), request.end_group()), (5, 0, 6)); + assert_eq!( + (request.start_group(), request.start_frame(), request.end_group()), + (5, 0, 6) + ); assert_eq!(request.priority(), 11); assert_eq!(request.next(), Some(5)); diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs index cd224230316..1477477e0ee 100644 --- a/rs/moq-hls/src/export/archive_tests.rs +++ b/rs/moq-hls/src/export/archive_tests.rs @@ -775,7 +775,12 @@ async fn history_after_expiry(finished: bool) { .await .unwrap() .ordered(); - let retained = retained.next_group().await.unwrap(); + // Polled once, before the reader serves anything: a fill would answer group 0 from the + // archive, so only what the cache still holds is ready now. + let retained = match retained.poll_next_group(&kio::Waiter::noop()) { + std::task::Poll::Ready(group) => group.unwrap(), + std::task::Poll::Pending => None, + }; assert!( retained.is_none_or(|group| group.sequence > 0), "the initial timeline groups must really be evicted" diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 4c939ab4dae..090cc4087ac 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -12818,7 +12818,10 @@ mod tests { } let subscription = serving_subscription(128); - assert_eq!(subscription.max_delay, None, "no Max Age is the publisher's retention alone"); + assert_eq!( + subscription.max_delay, None, + "no Max Age is the publisher's retention alone" + ); let mut subscriber = producer.subscribe(subscription); for sequence in [0, 1] { let group = subscriber diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index c1a3b7f5be7..2cab3e50123 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -497,6 +497,8 @@ struct Idle { dynamic: track::Dynamic, demand: track::Demand, timescale: Option, + /// The publisher ended the track, so a returning subscriber reads the copy as it is. + finished: bool, } struct TrackState { @@ -2675,8 +2677,8 @@ where } /// Subscribe upstream for `target`, until the subscription ends: `Some` once nobody - /// subscribes any more, with the copy kept for [`Self::linger`], `None` once the track - /// ended or failed. + /// subscribes any more or the track ended, with the copy kept for [`Self::linger`], + /// `None` once it failed. async fn subscribe_once( &mut self, broadcast_path: &Path<'_>, @@ -2752,13 +2754,8 @@ where let max_age_supported = self.peer_setup.get().await.max_age; let aggregate = target.subscription(); let priority = aggregate.as_ref().map(|s| s.priority).unwrap_or(0); - let max_age = max_age_supported.then(|| { - super::max_age::millis( - aggregate - .as_ref() - .map_or(Duration::MAX, |s| s.budget()), - ) - }); + let max_age = + max_age_supported.then(|| super::max_age::millis(aggregate.as_ref().map_or(Duration::MAX, |s| s.budget()))); let order = aggregate.map(|s| s.order).unwrap_or_default(); // Without an Established subscription, a relay includes the request it holds. let new_group = target @@ -3096,6 +3093,8 @@ where let mut fetch_done = fetching.is_none(); // Our grant stopped covering the track, which is aborted rather than kept lingering. let mut revoked = false; + // The publisher ended the track cleanly: the copy lingers for its history's fills. + let mut finished = false; let mut demand = track.demand(); // Nobody subscribing at all (only fetches asked) needs no subscription. let mut subscribed = track.subscription().is_some(); @@ -3210,9 +3209,12 @@ where break true; } End::Fill(requested) => { - let fetch = self - .clone() - .serve_fill(broadcast_path.to_owned(), track_name.to_owned(), requested, timescale); + let fetch = self.clone().serve_fill( + broadcast_path.to_owned(), + track_name.to_owned(), + requested, + timescale, + ); group_fetches.push(fetch); } End::Idle => { @@ -3257,6 +3259,7 @@ where track.set_tail_pending(false); // A no-op once an END_OF_TRACK declared the end. let _ = track.finish(); + finished = true; } Err(err) => { tracing::debug!(%err, "subscribe ended with error"); @@ -3279,7 +3282,15 @@ where if !idle { // The publisher already ended the request, so a FIN is all we owe it. stream.writer.finish().ok(); - return None; + // A reader may still miss history the publisher holds, such as a group whose + // fetch was in flight as the track ended, so a finished copy keeps serving fills. + return (finished && !aborted).then(|| Idle { + demand: track.demand(), + track, + dynamic, + timescale, + finished: true, + }); } // What the copy cached goes stale from here. Marked before the cancel, which waits // on the publisher: it stops serving as soon as the cancel lands, and a reader @@ -3297,12 +3308,13 @@ where track, dynamic, timescale, + finished: false, }) } /// Keep a copy nobody subscribes to, cache and all, for fetches and a subscriber /// returning soon: `Some` once one does, to subscribe again, `None` once nobody held - /// it through the linger, or the session ended. + /// it through the linger, or the session ended. A finished copy never subscribes again. async fn linger( &mut self, broadcast_path: &Path<'_>, @@ -3343,7 +3355,7 @@ where while let Poll::Ready(Ok(subscription)) = idle.track.poll_subscription_changed(waiter) { subscribed = subscription.is_some(); } - if subscribed { + if subscribed && !idle.finished { return Poll::Ready(Step::Subscribe); } // Nobody holds it: let it go after the linger. A reader waiting on a fetch holds @@ -3364,12 +3376,9 @@ where match step { Step::Fill(fill) => { - let fetch = self.clone().serve_fill( - broadcast_path.to_owned(), - track_name.to_owned(), - fill, - idle.timescale, - ); + let fetch = + self.clone() + .serve_fill(broadcast_path.to_owned(), track_name.to_owned(), fill, idle.timescale); group_fetches.push(fetch); } Step::Subscribe => break true, @@ -3872,6 +3881,7 @@ where track, dynamic, timescale: ok.properties.timescale, + finished: false, }) } @@ -15259,7 +15269,11 @@ mod joining_fetch_tests { assert_eq!(group.read_frame().await.unwrap().unwrap().payload.as_ref(), b"x"); // The grace runs out with the head still being written, ending the subscription. - (&mut run.serving).await.unwrap(); + // The finished copy lingers for fills while the reader holds it. + while !run.subscriber.state.lock().subscribes.is_empty() { + moq_clock::sleep(Duration::from_millis(1)).await; + } + assert!(!run.serving.is_finished(), "{version}: the finished copy lingers"); data.close(crate::lite::test_transport::Close::Fin); assert!( diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index e493b3d2096..4fd47bc91e7 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -5432,8 +5432,9 @@ mod serve_group_test { track.finish().unwrap(); run.await.unwrap(); - // SUBSCRIBE_START at 0, SUBSCRIBE_END at 3, and a DROP for the skipped group 1. - assert_eq!(*log.writes.lock().unwrap(), [0, 3, 0, 1, 0, 1, 1, 3, 2, 3, 1, 1, 0]); + // SUBSCRIBE_START at 0, a DROP for the skipped group 1, which the end gives up on at + // once, and SUBSCRIBE_END at 3. + assert_eq!(*log.writes.lock().unwrap(), [0, 3, 0, 1, 0, 2, 3, 1, 1, 0, 1, 1, 3]); } /// A relay that holds only the first group of a backlog answers with the largest its diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 936196f4aa4..93091907882 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2127,7 +2127,11 @@ mod tests { /// Many group fetches on one track share model state, but never share readiness. fn feed_batch( sequences: impl IntoIterator, - ) -> (track::Producer, Vec>, Vec) { + ) -> ( + track::Producer, + Vec>, + Vec, + ) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(Arc::new(crate::broadcast::Info::default()), "video", info); let dynamic = track.dynamic(); @@ -6923,10 +6927,10 @@ impl ServeLoop { // The groups the SUBSCRIBE asked for below it are not // waited for, whatever the demand asks later. One that // still arrives is delivered, so they are not dropped. - if let Some(requested) = active.requested { - if let Ok(mut tail) = active.tail.write() { - tail.account(requested.group..start.group, serve.subscriber.runtime.now()); - } + if let Some(requested) = active.requested + && let Ok(mut tail) = active.tail.write() + { + tail.account(requested.group..start.group, serve.subscriber.runtime.now()); } } // The publisher will never send these groups, so they @@ -7087,11 +7091,11 @@ enum FetchRunState { }, Ingest { stream: Stream, - producer: crate::recv::Group, + // Boxed, as is the ingest, so the other states stay small. + producer: Box, // The accepted request's demand: fetches that joined it but have not yet // picked the group up from the cache. joined: kio::Producer, - // Boxed so the other states stay small; one allocation per fetch. ingest: Box, }, Done, @@ -7340,7 +7344,7 @@ impl kio::Task for FetchServeRun { self.state = FetchRunState::Ingest { stream, - producer: crate::recv::Group::new(producer), + producer: Box::new(crate::recv::Group::new(producer)), joined, ingest: Box::new(FrameIngest::new( &self.serve.subscriber, diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index d24e2a3d368..d7f5b1f614e 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1821,7 +1821,10 @@ mod test { let whole = [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]; assert_eq!(read_cached(&b.consume(), 0), whole); assert_eq!(read_cached(&logical.consume(), 0), whole); - assert!(dynamic.requested_fill_group().now_or_never().is_none(), "no second fetch"); + assert!( + dynamic.requested_fill_group().now_or_never().is_none(), + "no second fetch" + ); } /// A replaced route that only stalled lends the replacement its head the same way. diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 05f920a0a89..26e6fc6bba4 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -288,6 +288,7 @@ impl Subscription { } /// [`Self::max_delay`] as a budget: `None` waits as long as anything could. + // private: passthrough pub(crate) fn budget(&self) -> Duration { self.max_delay.unwrap_or(Duration::MAX) } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 50946f3cadf..e889e25748b 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16,9 +16,9 @@ use crate::{Error, Result, Timescale, Timestamp, coding}; use crate::{broadcast, cache, group, stats}; +use super::Datagram; use super::delivered::Delivered; use super::ledger::{self, Ledger}; -use super::Datagram; use super::Cap; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; @@ -32,9 +32,10 @@ use demand::Subscriptions; mod fill; mod miss; pub use fill::Fill; +use fill::FillState; + // private: capability gap pub(crate) use fill::{GroupFill, Outcome as FillOutcome}; -use fill::FillState; use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, @@ -3168,6 +3169,7 @@ impl Dynamic { /// The next fill, which a test expects to name a single group. #[cfg(test)] + // private: test pub(crate) fn poll_requested_fill_group(&self, waiter: &kio::Waiter) -> Poll> { self.poll_requested_fill(waiter).map_ok(|fill| { assert_eq!(fill.end() - fill.start().group, 1, "a one-group fill"); @@ -3177,6 +3179,7 @@ impl Dynamic { /// [`Self::poll_requested_fill_group`], awaited. #[cfg(all(test, feature = "async"))] + // private: test pub(crate) async fn requested_fill_group(&self) -> Result { kio::wait(|waiter| self.poll_requested_fill_group(waiter)).await } @@ -4543,11 +4546,13 @@ impl kio::Task for Fetching { // An answered fill fails a joined fetch the cache cannot cover. The channel // closing without an answer means its handlers went away. - let (err, asked) = match result.outcome().poll(waiter, |outcome| match outcome.refused(sequence) { - Some(err) => Poll::Ready((err.clone(), outcome.asked())), - None if outcome.is_accepted(sequence) => Poll::Ready((Error::NotFound, None)), - None => Poll::Pending, - }) { + let (err, asked) = match result + .outcome() + .poll(waiter, |outcome| match outcome.refused(sequence) { + Some(err) => Poll::Ready((err.clone(), outcome.asked())), + None if outcome.is_accepted(sequence) => Poll::Ready((Error::NotFound, None)), + None => Poll::Pending, + }) { Poll::Ready(Ok(res)) => res, Poll::Ready(Err(_closed)) => (Error::NotFound, None), Poll::Pending => return Poll::Pending, @@ -5426,7 +5431,14 @@ impl Cursor { } let asks = { let state = self.state.read(); - misses.judge(&state, (from, revision, cap), || admitted(window, from, cap), drift, &mut given_up, waiter) + misses.judge( + &state, + (from, revision, cap), + || admitted(window, from, cap), + drift, + &mut given_up, + waiter, + ) }; misses.ask(&self.state, &fills, &asks, &asking, &mut given_up); loop { @@ -8755,7 +8767,10 @@ mod test { subscriber .update(Subscription::default().with_max_delay(Duration::from_millis(500))) .unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_millis(500))); + assert_eq!( + producer.subscription().unwrap().max_delay, + Some(Duration::from_millis(500)) + ); subscriber .update(Subscription::default().with_max_delay(Duration::ZERO)) @@ -13009,7 +13024,12 @@ mod test { live.start_at(1).unwrap(); live.write_frame(Timestamp::ZERO, b"delta".as_slice()).unwrap(); let fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_fill_group().await.unwrap().accept(Info::timed()).unwrap(); + let mut head = dynamic + .requested_fill_group() + .await + .unwrap() + .accept(Info::timed()) + .unwrap(); for _ in 0..3 { head.write_frame(Timestamp::ZERO, b"x".as_slice()).unwrap(); } @@ -13158,7 +13178,12 @@ mod test { .unwrap(); live.finish().unwrap(); let fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_fill_group().await.unwrap().accept(Info::timed()).unwrap(); + let mut head = dynamic + .requested_fill_group() + .await + .unwrap() + .accept(Info::timed()) + .unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from(vec![0u8; 10_000])) .unwrap(); head.finish().unwrap(); @@ -14579,14 +14604,16 @@ mod test { fn write_filled(group: Result, millis: u64) { let mut group = group.unwrap(); - group.write_frame(Timestamp::from_millis(millis).unwrap(), &b"x"[..]).unwrap(); + group + .write_frame(Timestamp::from_millis(millis).unwrap(), &b"x"[..]) + .unwrap(); group.finish().unwrap(); } - fn drained_drops(subscriber: &mut Subscriber) -> Vec> { + fn drained_drops(subscriber: &mut Subscriber) -> Vec<(u64, u64)> { let mut dropped = Vec::new(); while let Poll::Ready(range) = subscriber.poll_drop(&kio::Waiter::noop()) { - dropped.push(range); + dropped.push((range.start, range.end)); } dropped } @@ -14619,8 +14646,11 @@ mod test { assert!(drained_drops(&mut subscriber).is_empty(), "group 1 may still arrive"); elapse(&producer, Duration::from_secs(31)); - assert_eq!(drained_drops(&mut subscriber), [1..2]); - assert!(dynamic.poll_requested_fill(&waiter).is_pending(), "a refusal is not asked again"); + assert_eq!(drained_drops(&mut subscriber), [(1, 2)]); + assert!( + dynamic.poll_requested_fill(&waiter).is_pending(), + "a refusal is not asked again" + ); } /// A group the handler refused that still arrives live is delivered. @@ -14641,7 +14671,29 @@ mod test { }; assert_eq!(group.sequence, 1); elapse(&producer, Duration::from_secs(31)); - assert_eq!(drained_drops(&mut subscriber), [0..1]); + assert_eq!(drained_drops(&mut subscriber), [(0, 1)]); + } + + /// A refused miss is named dropped as soon as the feed ends, since nothing more arrives, + /// and the read ends with it. + #[test] + #[cfg(feature = "async")] + fn a_refused_miss_is_dropped_once_the_feed_ends() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + fill.drop_groups(0..2); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!( + drained_drops(&mut subscriber).is_empty(), + "the feed may still deliver them" + ); + producer.finish().unwrap(); + assert_eq!(drained_drops(&mut subscriber), [(0, 2)]); + assert!(matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Ok(None)))); } /// With no handler to fill it, a miss is named dropped once the feed gives up on it. @@ -14659,7 +14711,7 @@ mod test { assert_eq!(read, [2, 3]); assert!(drained_drops(&mut subscriber).is_empty()); elapse(&producer, Duration::from_secs(31)); - assert_eq!(drained_drops(&mut subscriber), [0..2]); + assert_eq!(drained_drops(&mut subscriber), [(0, 2)]); } /// An ordered reader waits on a fill for its miss rather than skipping past it. diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index bd5bcf6d12f..1665c4defc1 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -60,6 +60,7 @@ pub(crate) struct Outcome { impl Outcome { /// Why `sequence` was refused, if it was. + // private: capability gap pub(crate) fn refused(&self, sequence: u64) -> Option<&Error> { self.refused .iter() @@ -206,7 +207,10 @@ impl FillState { .collect(); let gaps: Vec> = self.covers.gaps(&span).collect(); for (groups, id) in covered { - let pending = self.pending.get_mut(&id).expect("a covered sequence names a pending fill"); + let pending = self + .pending + .get_mut(&id) + .expect("a covered sequence names a pending fill"); // Widened only while queued: once taken, the handler holds what it was asked. if pending.queued { pending.priority = pending.priority.max(ask.priority); @@ -666,54 +670,64 @@ pub(crate) struct GroupFill { impl GroupFill { /// The group sequence to fetch. + // private: capability gap pub(crate) fn sequence(&self) -> u64 { self.sequence } /// The delivery priority the readers asked for. + // private: capability gap pub(crate) fn priority(&self) -> u8 { self.inner.priority } /// The widest budget among the readers, for a handler that forwards it. + // private: capability gap pub(crate) fn max_delay(&self) -> Option { self.inner.max_delay } /// The first frame of the group the readers want; 0 is the whole group. + // private: capability gap pub(crate) fn frame_start(&self) -> u64 { self.frame_start } /// Poll until no reader waits on the fill. + // private: capability gap pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { self.inner.result.poll_unused(waiter).map(|_| ()) } /// Whether a reader still waits on the fill. #[cfg(test)] + // private: capability gap pub(crate) fn is_used(&self) -> bool { self.inner.result.is_used() } /// The fill's readers, for a session keeping a group alive while any still wait. + // private: capability gap pub(crate) fn joined(&self) -> kio::Producer { self.inner.result.clone() } /// The track the fill serves. #[cfg_attr(not(feature = "async"), allow(dead_code))] + // private: capability gap pub(crate) fn track(&self) -> kio::Producer { self.inner.state.clone() } /// Insert the group into the cache, as [`Fill::accept`]. + // private: capability gap pub(crate) fn accept(self, info: impl Into>) -> Result { self.inner.accept(self.sequence, info.into()) } /// Refuse the group with the source's answer. A miss names it dropped, and /// [`Error::Old`] every older group of the fill too. + // private: capability gap pub(crate) fn reject(self, err: Error) { self.inner.reject(self.sequence, err); } @@ -721,6 +735,7 @@ impl GroupFill { /// Declare the track's exclusive final sequence, as the source answering this group /// reported it. A no-op once the track declared an end, or holds a later group. #[cfg_attr(not(feature = "async"), allow(dead_code))] + // private: capability gap pub(crate) fn finish_track_at(&self, final_sequence: u64) { if let Ok(mut state) = TrackState::modify(&self.inner.state) && state.end.is_none() diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index e4d373d5a23..881a7483290 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -33,12 +33,15 @@ pub(super) struct Asking { pub order: Order, } +/// What a probe judges against: the span read, the feed's newest group, where the feed +/// and readers start, and the drops revision. +type JudgedAt = (u64, u64, Option, Option, Option, Option, u64); + /// The misses of one cursor, judged as what it reads or the feed moves. #[derive(Default)] pub(super) struct Misses { - /// What the last probe judged against: the span read, the feed's newest group, and - /// where the feed starts and readers start. A probe that sees the same skips judging. - at: Option<(u64, u64, Option, Option, Option, Option, u64)>, + /// What the last probe judged against. A probe that sees the same skips judging. + at: Option, /// The sequences judged already, within what the reader reads up to the newest group. judged: RangeSet, /// Holes the live feed may still deliver, judged again on every probe. @@ -240,7 +243,10 @@ impl Misses { let judged: RangeSet = self .judged .iter() - .flat_map(|run| span.overlapping(run).map(move |s| s.start.max(run.start)..s.end.min(run.end))) + .flat_map(|run| { + span.overlapping(run) + .map(move |s| s.start.max(run.start)..s.end.min(run.end)) + }) .collect(); let mut candidates: RangeSet = span.iter().flat_map(|run| judged.gaps(run)).collect(); for run in std::mem::take(&mut self.deferred).iter() { @@ -406,7 +412,8 @@ impl TrackState { /// deliver and the gaps it still waits for, with when each stops being waited for. /// /// Never: accounted for by its arrivals (evicted, or a gap it skipped), or in a gap its - /// grace gave up on. A declared start is no promise: a group below it may still arrive. + /// grace gave up on, or anything once the feed is over. A declared start is no promise: + /// a group below it may still arrive. #[allow(clippy::type_complexity)] pub(super) fn unfed( &self, @@ -415,15 +422,18 @@ impl TrackState { ) -> (RangeSet, Vec<(ops::Range, Option)>) { let mut unfed = RangeSet::new(); let mut awaited = Vec::new(); + // Nothing more arrives after an abort, a seal, or a settled end. + if self.abort.is_some() || self.sealed || (self.end.is_some() && !self.tail_pending) { + unfed.insert(hole); + return (unfed, awaited); + } let mut from = hole.start; let grace = self.arrivals.grace(); - let mut gap = |groups: ops::Range, unfed: &mut RangeSet| match self - .arrivals - .folds_at(groups.start, grace) - { - Some(at) if at <= now => unfed.insert(groups), - folds => awaited.push((groups, folds)), - }; + let mut gap = + |groups: ops::Range, unfed: &mut RangeSet| match self.arrivals.folds_at(groups.start, grace) { + Some(at) if at <= now => unfed.insert(groups), + folds => awaited.push((groups, folds)), + }; for arrived in self.arrivals.accounted(from..hole.end) { if from < arrived.start { gap(from..arrived.start, &mut unfed); diff --git a/rs/moq-net/tests/integration/fetch_cancel.rs b/rs/moq-net/tests/integration/fetch_cancel.rs index a3df1220d47..1609c164f85 100644 --- a/rs/moq-net/tests/integration/fetch_cancel.rs +++ b/rs/moq-net/tests/integration/fetch_cancel.rs @@ -75,12 +75,9 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage match stage { Stage::Unanswered => { drop(waiting); - moq_clock::timeout( - Duration::from_secs(5), - kio::wait(|waiter| request.poll_unused(waiter)), - ) - .await - .unwrap_or_else(|_| panic!("{ctx}: the publisher's fetch is still wanted")); + moq_clock::timeout(Duration::from_secs(5), kio::wait(|waiter| request.poll_unused(waiter))) + .await + .unwrap_or_else(|_| panic!("{ctx}: the publisher's fetch is still wanted")); } Stage::MidResponse => { let mut group = request.accept(0, timed()).unwrap(); diff --git a/rs/moq-net/tests/integration/fetch_caps.rs b/rs/moq-net/tests/integration/fetch_caps.rs index fbf26cdec4c..8d9050837ad 100644 --- a/rs/moq-net/tests/integration/fetch_caps.rs +++ b/rs/moq-net/tests/integration/fetch_caps.rs @@ -83,10 +83,7 @@ async fn an_over_cap_peer_cannot_cancel_another_peers_held_fetch() { matches!(err, moq_net::Error::Session(moq_net::SessionError::TooManyRequests)), "{version}: {err}" ); - assert!( - request_b.is_used(), - "{version}: the other peer still holds its request" - ); + assert!(request_b.is_used(), "{version}: the other peer still holds its request"); let mut group = request_b.accept(8, info).unwrap(); group.write_frame(None, &b"other peer"[..]).unwrap(); group.finish().unwrap(); diff --git a/test/drill/mutations/subscriber-max-age-ignored.patch b/test/drill/mutations/subscriber-max-age-ignored.patch index 40d5e93dbe3..5119debc7f1 100644 --- a/test/drill/mutations/subscriber-max-age-ignored.patch +++ b/test/drill/mutations/subscriber-max-age-ignored.patch @@ -9,7 +9,7 @@ // a join, which the track's refresh ratio judges. let mut subscription = serving_subscription(msg.subscriber_priority).with_order(order); - if let Some(max_age) = msg.max_age { -- subscription.max_delay = Duration::from_millis(max_age); +- subscription.max_delay = Some(Duration::from_millis(max_age)); - } // A range that resolves without the live edge has a fixed start, which is no // join: bound the subscription before subscribing so the ratio never judges it. From 2b2313bd075efe5281b7b0ea1c309acc7ee12f57 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 00:40:28 -0700 Subject: [PATCH 09/48] fix(interop): point the Go client's MaxDelay at a duration Co-Authored-By: Claude Opus 5.5 --- test/interop/clients/go/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/interop/clients/go/main.go b/test/interop/clients/go/main.go index 1763efd09df..a532aebbc2e 100644 --- a/test/interop/clients/go/main.go +++ b/test/interop/clients/go/main.go @@ -208,7 +208,8 @@ func subscribe(ctx context.Context, url, broadcast string, timeout time.Duration break } - media, err := moqmedia.NewTrackConsumer(ctx, consumer, moqmedia.TrackConfig{Rendition: moqmedia.VideoRendition(id, video), Subscription: &moq.Subscription{MaxDelay: maxDelay}}) + delay := maxDelay + media, err := moqmedia.NewTrackConsumer(ctx, consumer, moqmedia.TrackConfig{Rendition: moqmedia.VideoRendition(id, video), Subscription: &moq.Subscription{MaxDelay: &delay}}) if err != nil { return err } From 6dc31358f5e49aaa1e246b56ea8c8db1a3c6d682 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 11:37:54 -0700 Subject: [PATCH 10/48] fix(net): settle wide fills by run, not group by group A pre-lite-05 relay held a fill for the live feed by expanding it into one held request per group, so a peer that made a miss wide (a feed starting far past a reader's floor) made the serve loop walk every sequence. A held fill now waits on one group at a time and settles what the feed gave up by run, judged against the cache and ledger. A reader whose fill ended unanswered likewise settles the run instead of each sequence. A declared end ahead of the live edge no longer ends the feed for misses: until the edge reaches it, a group below it may still arrive. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 170 ++++++++++++++++++++--------- rs/moq-net/src/model/track.rs | 80 ++++++++++++++ rs/moq-net/src/model/track/fill.rs | 8 ++ rs/moq-net/src/model/track/miss.rs | 17 +-- 4 files changed, 213 insertions(+), 62 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 7af48db004e..6d6c24bf9a6 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1989,12 +1989,12 @@ mod tests { const VERSION: Version = Version::Lite05; /// A pre-lite-05 relay's track, untimed, with a fetch of `sequence` queued on it. - fn feed_fetch(sequence: u64) -> (track::Producer, kio::Pending, track::GroupFill) { + fn feed_fetch(sequence: u64) -> (track::Producer, kio::Pending, track::Fill) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(std::sync::Arc::new(crate::broadcast::Info::default()), "video", info); let dynamic = track.dynamic(); let fetching = track.consume().fetch_group(sequence, None); - let req = dynamic.requested_fill_group().now_or_never().expect("queued").unwrap(); + let req = dynamic.requested_fill().now_or_never().expect("queued").unwrap(); (track, fetching, req) } @@ -2038,7 +2038,7 @@ mod tests { let (track, fetching, req) = feed_fetch(100); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &kio::Waiter::noop()); assert_eq!(fetches.held.len(), 1, "held while its caller waits"); @@ -2059,7 +2059,7 @@ mod tests { arrive(&track, &tail, 0, start); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); arrive(&track, &tail, 2, start); let late = start + crate::tail::GRACE / 2; fetches.poll(&sub, late, &kio::Waiter::noop()); @@ -2088,7 +2088,7 @@ mod tests { arrive(&track, &tail, 2, start); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start + crate::tail::GRACE, &kio::Waiter::noop()); assert!(fetches.held.is_empty()); assert!(matches!(fetching.now_or_never(), Some(Err(Error::NotFound)))); @@ -2140,7 +2140,7 @@ mod tests { let now = crate::time::Instant::now(); let clock = crate::time::Deadlines::new(now); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, now, &kio::Waiter::noop()); assert_eq!( fetches.held.len(), @@ -2166,7 +2166,7 @@ mod tests { ) -> ( track::Producer, Vec>, - Vec, + Vec, ) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(Arc::new(crate::broadcast::Info::default()), "video", info); @@ -2175,7 +2175,7 @@ mod tests { let mut requests = Vec::new(); for sequence in sequences { callers.push(track.consume().fetch_group(sequence, None)); - requests.push(dynamic.requested_fill_group().now_or_never().expect("queued").unwrap()); + requests.push(dynamic.requested_fill().now_or_never().expect("queued").unwrap()); } (track, callers, requests) } @@ -2197,7 +2197,7 @@ mod tests { let wakes = Arc::new(Wakes::default()); let waiter = kio::Waiter::new(std::task::Waker::from(wakes.clone())); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &waiter); wakes.0.store(0, atomic::Ordering::Relaxed); drop(caller); @@ -2243,7 +2243,7 @@ mod tests { let mut fetches = FeedFetches::new(&clock); let (track, _callers, requests) = feed_batch(0..32); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } let waiter = kio::Waiter::noop(); fetches.poll(&sub, start, &waiter); @@ -2264,7 +2264,7 @@ mod tests { let (track, _callers, requests) = feed_batch([1, 3, 5, 1000]); let mut fetches = FeedFetches::new(&clock); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } arrive(&track, &tail, 0, start); arrive(&track, &tail, 2, start); @@ -2292,7 +2292,7 @@ mod tests { let (mut track, _callers, requests) = feed_batch([1, 3, 5, 1000]); let mut fetches = FeedFetches::new(&clock); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } let waiter = kio::Waiter::noop(); fetches.poll(&sub, start, &waiter); @@ -2321,7 +2321,7 @@ mod tests { let (track, mut callers, requests) = feed_batch([1, 3, 5]); let mut fetches = FeedFetches::new(&clock); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } let waiter = kio::Waiter::noop(); fetches.poll(&sub, start, &waiter); @@ -2334,6 +2334,44 @@ mod tests { assert!(matches!(callers.remove(0).now_or_never(), Some(Err(Error::NotFound)))); } + /// A peer that starts its feed far past a reader's floor makes the reader's miss wide. + /// Holding it settles what the feed gave up by run, in one poll, rather than holding or + /// refusing its groups one at a time. + #[moq_clock::test] + async fn a_wide_feed_fill_settles_by_run() { + const WIDE: u64 = 1 << 40; + let start = crate::time::Instant::now(); + let clock = crate::time::Deadlines::new(start); + let tail = kio::Producer::new(Tail::default()); + let sub = feed_sub(&tail).await; + let info = track::Info::default().with_timescale(None); + let mut track = track::Producer::new(Arc::new(crate::broadcast::Info::default()), "video", info); + let dynamic = track.dynamic(); + let mut reader = track.subscribe( + Subscription::default() + .with_groups(0..) + .with_max_delay(Duration::from_secs(30)), + ); + track.start_at(WIDE).unwrap(); + arrive(&track, &tail, WIDE, start); + let waiter = kio::Waiter::noop(); + // Reading judges the reader's misses. + let _ = reader.poll_recv_group(&waiter); + let fill = dynamic.requested_fill().now_or_never().expect("queued").unwrap(); + assert_eq!((fill.start().group, fill.end()), (0, WIDE)); + + let mut fetches = FeedFetches::new(&clock); + fetches.push(fill, &track.consume()); + fetches.poll(&sub, start, &waiter); + assert!(fetches.held.is_empty(), "the feed starts past every group of it"); + assert_eq!(fetches.polled, 1); + assert!(reader.poll_recv_group(&waiter).is_pending()); + assert!( + dynamic.requested_fill().now_or_never().is_none(), + "a refusal is not asked again" + ); + } + #[moq_clock::test] async fn the_feed_track_backstop_refuses_the_next_group() { let start = crate::time::Instant::now(); @@ -2344,7 +2382,7 @@ mod tests { let mut fetches = FeedFetches::new(&clock); fetches.max = 2; for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } fetches.poll(&sub, start, &kio::Waiter::noop()); assert_eq!(fetches.held.len(), 2); @@ -2364,16 +2402,16 @@ mod tests { let (track, caller, req) = feed_fetch(1); let dynamic = track.dynamic(); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &kio::Waiter::noop()); drop(caller); let next = track.consume().fetch_group(1, None); let req = dynamic - .requested_fill_group() + .requested_fill() .now_or_never() .expect("replacement queued") .unwrap(); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &kio::Waiter::noop()); assert_eq!(fetches.held.len(), 1); arrive(&track, &tail, 1, start); @@ -2471,9 +2509,9 @@ mod tests { let mut fetches = FeedFetches::new(&clock); for req in requests { if indexed { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } else { - scan.held.push((req, track.consume())); + scan.held.push((req.group(req.start().group), track.consume())); } } if indexed { @@ -6540,10 +6578,15 @@ enum ServeMode { }, } -/// Fetches a pre-lite-05 upstream has no FETCH for, held while the live subscription may -/// still deliver the group. A group the feed delivers lands in the cache, where a waiting -/// fetch reads it once its request is dropped. +/// Fills a pre-lite-05 upstream has no FETCH for, held while the live subscription may +/// still deliver their groups. A group the feed delivers lands in the cache, where the fill +/// answers it. +/// +/// A fill waits on one group at a time, the next it still owes. Each verdict on that group +/// also settles, by run, every group of the fill the feed gave up on, so a fill a peer +/// made wide costs what the cache and ledger hold rather than its width. struct FeedFetches { + /// Each fill, by the group it waits on. held: BTreeMap, ready: kio::Shared>, arrivals: Option, @@ -6556,8 +6599,10 @@ struct FeedFetches { } struct HeldFetch { - req: track::GroupFill, + fill: track::Fill, waiter: kio::Waiter, + /// Not judged yet: what the feed already gave up on is settled at once. + fresh: bool, } struct FetchWake { @@ -6587,16 +6632,10 @@ impl FeedFetches { } } - /// Hold every group of `fill` for the live feed. - fn push_fill(&mut self, fill: track::Fill, feed: &track::Consumer) { - for sequence in fill.start().group..fill.end() { - self.push(fill.group(sequence), feed.clone()); - } - } - - fn push(&mut self, req: track::GroupFill, feed: track::Consumer) { + /// Hold `fill` for the live feed. + fn push(&mut self, fill: track::Fill, feed: &track::Consumer) { let Some(arrivals) = feed.arrivals() else { - req.reject(Error::NotFound); + fill.drop_groups(fill.start().group..fill.end()); return; }; if let Some(previous) = &self.arrivals { @@ -6604,21 +6643,28 @@ impl FeedFetches { } else { self.arrivals = Some(arrivals); } - let sequence = req.sequence(); + if self.held.len() >= self.max { + fill.refuse(Error::TooManyRequests); + return; + } + self.hold(fill, true); + } + + /// Wait on the next group `fill` still owes, if any. + fn hold(&mut self, fill: track::Fill, fresh: bool) { + let Some(sequence) = fill.next() else { + return; + }; // The last caller may leave and a replacement attempt arrive before the // queued cancellation gets polled. The old attempt owns no callers now. if let Some(previous) = self.held.remove(&sequence) { - debug_assert!(previous.req.poll_unused(&kio::Waiter::noop()).is_ready()); - } - if self.held.len() >= self.max { - req.reject(Error::TooManyRequests); - return; + debug_assert!(previous.fill.poll_unused(&kio::Waiter::noop()).is_ready()); } let waiter = kio::Waiter::new(std::task::Waker::from(Arc::new(FetchWake { sequence, ready: self.ready.clone(), }))); - self.held.insert(sequence, HeldFetch { req, waiter }); + self.held.insert(sequence, HeldFetch { fill, waiter, fresh }); self.ready.lock().insert(sequence); } @@ -6630,7 +6676,7 @@ impl FeedFetches { ) { let Sub::Active(_) = sub else { for (_, held) in std::mem::take(&mut self.held) { - held.req.reject(Error::NotFound); + held.fill.drop_groups(held.fill.start().group..held.fill.end()); } let mut ready = self.ready.lock(); if !ready.is_empty() { @@ -6671,37 +6717,53 @@ impl FeedFetches { Poll::Pending => BTreeSet::new(), }; let _ = self.ready.poll(waiter, |_| Poll::<()>::Pending); + let mut unused = Vec::new(); let mut settled = Vec::new(); for sequence in ready { - let Some(held) = self.held.remove(&sequence) else { + let Some(mut held) = self.held.remove(&sequence) else { continue; }; #[cfg(test)] { self.polled += 1; } - if held.req.poll_unused(&held.waiter).is_ready() { - settled.push((held.req, None)); + if held.fill.poll_unused(&held.waiter).is_ready() { + unused.push(held.fill); continue; } - match arrivals.poll_group(sequence, now, &held.waiter) { - Poll::Ready(Some(_)) => settled.push((held.req, None)), - Poll::Ready(None) => settled.push((held.req, Some(Error::NotFound))), - Poll::Pending => { - self.held.insert(sequence, held); - } + let verdict = arrivals.poll_group(sequence, now, &held.waiter); + if verdict.is_pending() && !held.fresh { + self.held.insert(sequence, held); + continue; } + let gone = arrivals.given_up(held.fill.start().group..held.fill.end(), now); + if verdict.is_pending() && gone.is_empty() { + held.fresh = false; + self.held.insert(sequence, held); + continue; + } + settled.push((held.fill, verdict.is_ready().then_some(sequence), gone)); } let fold = arrivals .folds() .filter_map(|(gap, at)| self.held.range(gap).next().map(|_| at)) .min(); - // Requests own track producers: releasing one takes the track lock too. + // Fills own track producers: answering or releasing one takes the track lock too. drop(arrivals); - for (req, err) in settled { - if let Some(err) = err { - req.reject(err); + drop(unused); + for (fill, decided, gone) in settled { + for groups in gone.iter() { + fill.drop_groups(groups.clone()); + } + // The group waited on is settled, so the fill moves past it: one landed without + // the frames the fill starts from is no answer, and the cache serves what it has. + if let Some(sequence) = decided + && fill.next() == Some(sequence) + { + fill.drop_groups(sequence..sequence + 1); } + // Waits on the next group it owes, judged on the poll its readiness wakes. + self.hold(fill, false); } self.fold.set(fold); let _ = self.fold.poll(waiter); @@ -6821,7 +6883,7 @@ impl ServeLoop { } else { // No FETCH on the wire: wait for the live feed, which a // subscription arriving alongside may still be opening. - self.feed_fetches.push_fill(fill, &self.serving.consume()); + self.feed_fetches.push(fill, &self.serving.consume()); } continue; } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 2b82af44017..cf080acdc21 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -3854,6 +3854,48 @@ impl ArrivalRead<'_> { res => res, } } + + /// The groups of `groups` [`Self::poll_group`] gives up on, judged by run, so a span a + /// peer made wide costs what the cache and ledger hold rather than its width. + // Legacy wires lack FETCH, so a held fill settles what the feed gave up in bulk. + // private: capability gap + pub(crate) fn given_up(&self, groups: ops::Range, now: crate::time::Instant) -> RangeSet { + let state = &*self.state; + let mut gone = RangeSet::new(); + let mut give_up = |groups: ops::Range| { + if !groups.is_empty() { + gone.insert(groups); + } + }; + let closed = self.state.is_closed(); + let start = state.start_sequence.unwrap_or(0); + let fin = state.final_sequence().unwrap_or(u64::MAX); + for hole in miss::holes(state, groups) { + if closed { + give_up(hole); + continue; + } + give_up(hole.start..hole.end.min(start)); + give_up(hole.start.max(fin)..hole.end); + for dropped in state.drops.overlapping(&hole) { + give_up(dropped.start.max(hole.start)..dropped.end.min(hole.end)); + } + // Every group of a gap folds at once: its grace runs from the run above it. + let folded = |gap: u64| state.arrivals.folds_at(gap, self.grace).is_some_and(|at| at <= now); + let mut from = hole.start; + for arrived in state.arrivals.accounted(hole.clone()) { + if from < arrived.start && folded(from) { + give_up(from..arrived.start); + } + from = arrived.end; + give_up(arrived); + } + if from < hole.end && folded(from) { + give_up(from..hole.end); + } + } + gone + } } /// A lazy handle to a single track within a broadcast. @@ -15022,6 +15064,24 @@ mod test { assert!(matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Ok(None)))); } + /// An end declared ahead of the live edge does not end the feed yet: a group missing + /// below the edge may still arrive, so it is not named dropped. + #[test] + #[cfg(feature = "async")] + fn an_end_ahead_of_the_edge_keeps_a_late_group() { + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_groups(0..)); + for sequence in [0, 1, 2, 4, 5] { + write_group(&producer, sequence); + } + producer.finish_at(Position::group(10)).unwrap(); + assert_eq!(drain_groups(&mut subscriber), [0, 1, 2, 4, 5]); + assert!(drained_drops(&mut subscriber).is_empty(), "group 3 may still arrive"); + write_group(&producer, 3); + assert_eq!(drain_groups(&mut subscriber), [3]); + assert!(drained_drops(&mut subscriber).is_empty()); + } + /// With no handler to fill it, a miss is named dropped once the feed gives up on it. #[test] #[cfg(feature = "async")] @@ -15040,6 +15100,26 @@ mod test { assert_eq!(drained_drops(&mut subscriber), [(0, 2)]); } + /// A fill whose handler leaves before taking it settles by run: a peer that starts its + /// feed far past the reader's floor makes the miss wide, and walking it group by group + /// would never finish. + #[test] + #[cfg(feature = "async")] + fn a_wide_fill_its_handler_left_settles_by_run() { + const WIDE: u64 = 1 << 40; + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_groups(0..)); + producer.start_at(WIDE).unwrap(); + write_group(&producer, WIDE); + assert_eq!(drain_groups(&mut subscriber), [WIDE]); + drop(dynamic); + assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); + assert!(drained_drops(&mut subscriber).is_empty(), "the feed may still deliver them"); + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [(0, WIDE)]); + } + /// An ordered reader waits on a fill for its miss rather than skipping past it. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 1665c4defc1..91566c1fa9b 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -491,6 +491,14 @@ impl Fill { } } + /// Refuse every group still owed with `err`, which says nothing about whether the source + /// has them, so nothing is recorded dropped. + // private: capability gap + pub(crate) fn refuse(&self, err: Error) { + let unresolved = self.inner.unresolved(self.inner.start.group..self.inner.end); + self.inner.refuse(&unresolved, err); + } + /// A handle on group `sequence` alone, for a session serving the fill one group at a time. // private: capability gap pub(crate) fn group(&self, sequence: u64) -> GroupFill { diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 881a7483290..a14a4bf6a06 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -175,13 +175,13 @@ impl Misses { for (groups, answer) in answers { self.wanted.remove(groups.clone()); match answer { + // What the cache holds is filled, and the rest settled by run: a fill that + // ended unanswered may span far more groups than were ever cached. Ok(()) => { - for sequence in groups { - match state.lookup.get(&sequence).is_some_and(|slot| !slot.is_aborted()) { - true => { - self.filled.insert(sequence); - } - false => self.settle(&state, sequence..sequence + 1, given_up), + self.settle(&state, groups.clone(), given_up); + for (&sequence, slot) in state.lookup.range(groups) { + if !slot.is_aborted() { + self.filled.insert(sequence); } } } @@ -422,8 +422,9 @@ impl TrackState { ) -> (RangeSet, Vec<(ops::Range, Option)>) { let mut unfed = RangeSet::new(); let mut awaited = Vec::new(); - // Nothing more arrives after an abort, a seal, or a settled end. - if self.abort.is_some() || self.sealed || (self.end.is_some() && !self.tail_pending) { + // Nothing more arrives after an abort, a seal, or once the live edge reached a + // declared end with no tail owed: until then, a group below the end may still arrive. + if self.abort.is_some() || self.is_complete() { unfed.insert(hole); return (unfed, awaited); } From 3e5ca7d58778a83e2a6e0775123ae23cb34029b6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:46:01 -0700 Subject: [PATCH 11/48] perf(net): bench fills over readers and runs, and keep a miss run flat `track_fill` sweeps readers missing the same runs below the feed's start, each run asked as one fill they share. It showed each run costing more the more runs a reader held: judging a run's staleness looked up its successor, and with several ranges that walked every range past it, all empty. The search now leapfrogs the ranges and the cache, so a range holding nothing costs one lookup. Per run, 1024 runs now cost about 1.4x what 64 do, down from about 5x. Judging a hole also reads only the claims that overlap it. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/benches/track.rs | 86 +++++++++++++++++++++++++++++- rs/moq-net/src/model/track.rs | 71 +++++++++++++++++++++--- rs/moq-net/src/model/track/miss.rs | 7 +-- 3 files changed, 152 insertions(+), 12 deletions(-) diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index f471d193563..ecd53bc9813 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -70,6 +70,11 @@ //! the readers: an unsettled time end walks on from where it stopped, so a rescan of the //! groups behind the gap on every read shows up as a slope over them. //! +//! `track_fill` has readers miss the same runs below where the feed starts, each asked of the +//! track's handler as a fill, swept over the readers and the runs: readers share each +//! fill, so asking, dropping, and settling must cost each reader the same per run, and a +//! cost that grows with the audience or the runs beyond that shows up as a slope. +//! //! Run with `cargo bench -p moq-net --bench track`. // Custom timings measure the real time the work took, not the ambient clock. @@ -1343,6 +1348,84 @@ fn bench_time_end(c: &mut Criterion) { group.finish(); } +/// Readers missing the same runs of one track below where its feed starts, and the handler +/// they ask; see [`bench_fill`]. +struct Fills { + _broadcast: broadcast::Producer, + _track: track::Producer, + dynamic: track::Dynamic, + readers: Vec, + runs: u64, +} + +impl Fills { + fn new(readers: usize, runs: u64) -> Self { + let broadcast = broadcast::Info::default().produce(); + let mut track = broadcast.create_track("bench", timed()).unwrap(); + let dynamic = track.dynamic(); + // The feed starts past every run, so each is a miss asked at once. + let start = runs * 2; + track.start_at(start).unwrap(); + let mut group = track.create_group(group::Info { sequence: start }).unwrap(); + group + .write_frame(Timestamp::from_millis(start).unwrap(), Bytes::from_static(b"x")) + .unwrap(); + group.finish().unwrap(); + // One range per run, with a sequence between each that no reader asks for. + let ranges = (0..runs) + .map(|run| track::Range::groups(run * 2..run * 2 + 1)) + .chain([track::Range::groups(start..)]); + let subscription = track::Subscription::default() + .with_ranges(ranges) + .with_max_delay(AGED_BUDGET); + let readers = (0..readers).map(|_| track.subscribe(subscription.clone())).collect(); + Self { + _broadcast: broadcast, + _track: track, + dynamic, + readers, + runs, + } + } + + /// Every reader reads what is cached and asks for its misses, the handler drops each + /// fill they share, and every reader settles the drops. + fn run(&mut self) { + let waiter = kio::Waiter::noop(); + for reader in &mut self.readers { + while let Poll::Ready(Ok(Some(group))) = reader.poll_recv_group(&waiter) { + black_box(group); + } + } + let mut fills = 0; + while let Poll::Ready(Ok(fill)) = self.dynamic.poll_requested_fill(&waiter) { + fill.drop_groups(fill.start().group..fill.end()); + fills += 1; + } + assert_eq!(fills, self.runs, "readers share each run's fill"); + for reader in &mut self.readers { + assert!(reader.poll_recv_group(&waiter).is_pending()); + } + } +} + +/// Readers missing the same runs below the feed's start, each asked as one fill they share, +/// swept over the readers and the runs. Each reader must pay the same per run, so a cost +/// that grows with the audience or the runs shows up as a slope. +fn bench_fill(c: &mut Criterion) { + let mut group = c.benchmark_group("track_fill"); + group.sample_size(10); + for readers in [1usize, 8, 64] { + for runs in [1u64, 64, 1024] { + group.throughput(Throughput::Elements(runs * readers as u64)); + group.bench_function(BenchmarkId::new(format!("{readers}r"), runs), |b| { + b.iter_batched_ref(|| Fills::new(readers, runs), Fills::run, BatchSize::PerIteration); + }); + } + } + group.finish(); +} + criterion_group!( benches, bench_range_widening, @@ -1365,6 +1448,7 @@ criterion_group!( bench_gap_flush, bench_wall, bench_empty_run, - bench_time_end + bench_time_end, + bench_fill ); criterion_main!(benches); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index cf080acdc21..815493a2c64 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -1103,8 +1103,9 @@ impl TrackState { } /// The first cached group at or above `from` that `servable` covers for which `f` - /// answers, lowest first or, with `rev`, highest first: one lookup for a single run, - /// one per run otherwise. + /// answers, lowest first or, with `rev`, highest first: one lookup for a single run. + /// Several runs leapfrog the cache, so a run holding nothing (a reader's misses) costs + /// one lookup rather than a walk over every run past it. fn find_servable<'a, R>( &'a self, servable: &Servable, @@ -1127,13 +1128,31 @@ impl TrackState { scan(self.lookup.range((Bound::Included(from), end))) } Servable::Runs(runs) => { - let mut runs = runs.overlapping(from..=u64::MAX); - let each = - |run: &ops::RangeInclusive| scan(self.lookup.range((*run.start()).max(from)..=*run.end())); - match rev { - true => runs.rev().find_map(each), - false => runs.find_map(each), + // What is left to search, shrinking from the end the search starts at. + let (mut lo, mut hi) = (from, u64::MAX); + while lo <= hi { + let mut left = runs.overlapping(lo..=hi); + let run = match rev { + true => left.next_back(), + false => left.next(), + }?; + let (start, end) = ((*run.start()).max(lo), (*run.end()).min(hi)); + // The cache from the run on, toward the far end: one lookup either finds + // the run's groups or the next cached group past a run holding none. + let leap = match rev { + true => leap(self.lookup.range(lo..=end).rev(), start..=end, &mut f), + false => leap(self.lookup.range(start..=hi), start..=end, &mut f), + }; + match leap { + Leap::Found(found) => return Some(found), + Leap::Empty => return None, + Leap::Past(sequence) if rev => hi = sequence, + Leap::Past(sequence) => lo = sequence, + Leap::Exhausted if rev => hi = start.checked_sub(1)?, + Leap::Exhausted => lo = end.checked_add(1)?, + } } + None } } } @@ -4976,6 +4995,42 @@ impl Published { } } +/// What one run's lookup found, for [`TrackState::find_servable`] leapfrogging the cache. +enum Leap { + /// A group in the run that the search answered. + Found(R), + /// The run's groups, none answering. + Exhausted, + /// Nothing in the run: the nearest cached group past it. + Past(u64), + /// Nothing cached from the run on. + Empty, +} + +/// Search `cached`, ordered from the run toward the far end, for a group in `run` that `f` +/// answers. +fn leap<'a, R>( + mut cached: impl Iterator, + run: ops::RangeInclusive, + f: &mut impl FnMut(&'a Slot) -> Option, +) -> Leap { + let Some((&first, slot)) = cached.next() else { + return Leap::Empty; + }; + if !run.contains(&first) { + return Leap::Past(first); + } + let mut within = std::iter::once(slot).chain( + cached + .take_while(|(sequence, _)| run.contains(*sequence)) + .map(|(_, slot)| slot), + ); + match within.find_map(f) { + Some(found) => Leap::Found(found), + None => Leap::Exhausted, + } +} + /// The groups a reader can be served, the only ones its drift is measured against: one it /// never reads, past its cap or in a gap between its ranges, never stands in for one it does. #[derive(Clone, PartialEq)] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index a14a4bf6a06..2f90e768387 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -367,13 +367,14 @@ impl Misses { /// The part of `hole` nobody has a verdict on yet. fn unclaimed(&self, state: &TrackState, hole: ops::Range) -> Vec> { let mut left = RangeSet::new(); - left.insert(hole); + left.insert(hole.clone()); + // Only what overlaps the hole, so judging every run costs each one the same. for claimed in [&state.drops, &self.given_up, &self.wanted] { - for run in claimed.iter() { + for run in claimed.overlapping(&hole) { left.remove(run.clone()); } } - for sequence in &self.filled { + for sequence in self.filled.range(hole) { left.remove(*sequence..*sequence + 1); } left.iter().cloned().collect() From 5e09300ec9c44b8bb9ed4a365d9fd91252b9f04b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:47:09 -0700 Subject: [PATCH 12/48] style(net): rustfmt the fill tests Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 6 +----- rs/moq-net/src/model/track.rs | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 6d6c24bf9a6..bcc2f384ed0 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2163,11 +2163,7 @@ mod tests { /// Many group fetches on one track share model state, but never share readiness. fn feed_batch( sequences: impl IntoIterator, - ) -> ( - track::Producer, - Vec>, - Vec, - ) { + ) -> (track::Producer, Vec>, Vec) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(Arc::new(crate::broadcast::Info::default()), "video", info); let dynamic = track.dynamic(); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 815493a2c64..7453f999707 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15170,7 +15170,10 @@ mod test { assert_eq!(drain_groups(&mut subscriber), [WIDE]); drop(dynamic); assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); - assert!(drained_drops(&mut subscriber).is_empty(), "the feed may still deliver them"); + assert!( + drained_drops(&mut subscriber).is_empty(), + "the feed may still deliver them" + ); elapse(&producer, Duration::from_secs(31)); assert_eq!(drained_drops(&mut subscriber), [(0, WIDE)]); } From 871214839837c943423a3971668644fcd4249d58 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:12:42 -0700 Subject: [PATCH 13/48] fix(net): a descending reader turns a queued fill newest first Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 24 ++++++++++++++++++++++++ rs/moq-net/src/model/track/fill.rs | 4 ++++ 2 files changed, 28 insertions(+) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 7453f999707..f2d46cfe254 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15178,6 +15178,30 @@ mod test { assert_eq!(drained_drops(&mut subscriber), [(0, WIDE)]); } + /// A descending reader joining a queued fill makes it newest first, as any descending + /// subscriber does a subscription. + #[test] + fn a_descending_reader_turns_a_queued_fill_newest_first() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut ascending = producer.subscribe(replay().with_groups(0..).with_order(Order::Ascending)); + let mut descending = producer.subscribe(replay().with_groups(0..)); + producer.start_at(4).unwrap(); + write_group(&producer, 4); + let _ = ascending.poll_recv_group(&kio::Waiter::noop()); + let _ = descending.poll_recv_group(&kio::Waiter::noop()); + + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) else { + panic!("the miss below the start is asked"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 4)); + assert_eq!(fill.order(), Order::Descending); + assert!( + dynamic.poll_requested_fill(&kio::Waiter::noop()).is_pending(), + "both readers share the fill" + ); + } + /// An ordered reader waits on a fill for its miss rather than skipping past it. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 91566c1fa9b..ed8e09aada8 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -215,6 +215,10 @@ impl FillState { if pending.queued { pending.priority = pending.priority.max(ask.priority); pending.max_delay = looser(pending.max_delay, ask.max_delay); + // Any descending reader makes it newest first, as it does a subscription. + if ask.order == Order::Descending { + pending.order = Order::Descending; + } if groups.start == pending.start.group && ask.start.group == groups.start { pending.start.frame = pending.start.frame.min(ask.start.frame); } From a65c8c676020911ddc6fcd941d7ab4678bfe7344 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:23:52 -0700 Subject: [PATCH 14/48] quest: point the resume-outage links at the PR that finished it Co-Authored-By: Claude Opus 5.5 --- quest/m1/js-start-parity.md | 2 +- quest/m2/lite-legacy-gap.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/quest/m1/js-start-parity.md b/quest/m1/js-start-parity.md index 72659b6a1f4..81719b80957 100644 --- a/quest/m1/js-start-parity.md +++ b/quest/m1/js-start-parity.md @@ -18,4 +18,4 @@ Public API: none. Wire: none, only the value JS sends. ## Related - [lite-05/06 gap judgement](/quest/m2/lite-legacy-gap.md) - the same misjudgement on versions whose START cannot say it -- [Resume through an outage](/quest/m1/resume-outage.md) - the resume work that found it +- [Resume through an outage](https://github.com/moq-dev/moq/pull/6183) - the resume work that found it diff --git a/quest/m2/lite-legacy-gap.md b/quest/m2/lite-legacy-gap.md index e06bfac683b..8d31fa76701 100644 --- a/quest/m2/lite-legacy-gap.md +++ b/quest/m2/lite-legacy-gap.md @@ -22,4 +22,4 @@ Public API: none. Wire: none. ## Related - [JS answers SUBSCRIBE_START like Rust](/quest/m1/js-start-parity.md) - the lite-07 publisher half -- [Resume through an outage](/quest/m1/resume-outage.md) - the lite-07 fix +- [Resume through an outage](https://github.com/moq-dev/moq/pull/6183) - the lite-07 fix From 6ab75235a333798bf7c11960ccd0359f8ccdd0be Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:52:12 -0700 Subject: [PATCH 15/48] fix(net): index a fill's answers so refusing it group by group stays linear Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 25 +++++++++++ rs/moq-net/src/model/track/fill.rs | 71 ++++++++++++++++++------------ 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index b6b1d8e2da6..d16c040cb8d 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15222,6 +15222,31 @@ mod test { assert_eq!(drained_drops(&mut subscriber), [(0, WIDE)]); } + /// A handler refusing a wide fill one group at a time, as a FETCH-capable session does, + /// pays for each refusal rather than for every refusal before it. + #[test] + #[cfg(feature = "async")] + fn a_fill_refused_group_by_group_stays_linear() { + const WIDE: u64 = 1 << 40; + const REFUSED: u64 = 100_000; + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_groups(0..)); + producer.start_at(WIDE).unwrap(); + write_group(&producer, WIDE); + assert_eq!(drain_groups(&mut subscriber), [WIDE]); + + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) else { + panic!("the miss below the start is asked"); + }; + for _ in 0..REFUSED { + let sequence = fill.next().unwrap(); + fill.drop_groups(sequence..sequence + 1); + assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); + } + assert_eq!(fill.next(), Some(WIDE - REFUSED - 1)); + } + /// A descending reader joining a queued fill makes it newest first, as any descending /// subscriber does a subscription. #[test] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index ed8e09aada8..e8aa64c7dd3 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -51,8 +51,12 @@ struct Pending { // pending: /quest/m1/public-model/deferred.md pub(crate) struct Outcome { accepted: RangeSet, - // Why each refused run was refused, in the order the handler refused them. - refused: Vec<(ops::Range, Error)>, + // Why each refused run was refused; refusing a sequence again replaces why. + refused: RangeMap, + refusals: u64, + // Accepted or refused, kept as answers arrive so a fill answered group by group costs + // the runs each answer touches, not every answer before it. + resolved: RangeSet, // The budget the fill asked its handler for, so a looser reader can tell that an // [`Error::Old`] was not its own. asked: Option, @@ -62,11 +66,7 @@ impl Outcome { /// Why `sequence` was refused, if it was. // private: capability gap pub(crate) fn refused(&self, sequence: u64) -> Option<&Error> { - self.refused - .iter() - .rev() - .find(|(groups, _)| groups.contains(&sequence)) - .map(|(_, err)| err) + self.refused.get(&sequence).map(|refusal| &refusal.err) } pub(super) fn is_accepted(&self, sequence: u64) -> bool { @@ -89,32 +89,46 @@ impl Outcome { for accepted in self.accepted.overlapping(run) { answers.push((accepted.start.max(run.start)..accepted.end.min(run.end), Ok(()))); } - for (refused, err) in &self.refused { + for (refused, refusal) in self.refused.overlapping(run) { let groups = refused.start.max(run.start)..refused.end.min(run.end); - if !groups.is_empty() { - answers.push((groups, Err((err.clone(), self.asked)))); - } + answers.push((groups, Err((refusal.err.clone(), self.asked)))); } } answers } - fn resolved(&self) -> RangeSet { - let mut resolved = self.accepted.clone(); - for (groups, _) in &self.refused { - resolved.insert(groups.clone()); - } - resolved + fn accept(&mut self, groups: ops::Range) { + self.accepted.insert(groups.clone()); + self.resolved.insert(groups); } fn refuse(&mut self, groups: ops::Range, err: Error) { if groups.is_empty() { return; } - self.refused.push((groups, err)); + let id = self.refusals; + self.refusals += 1; + self.refused.insert(groups.clone(), Refusal { id, err }); + self.resolved.insert(groups); + } +} + +/// One refusal, equal only to itself: the map keeps a run it refused whole, but never merges +/// two refusals whose errors may differ. +#[derive(Clone)] +struct Refusal { + id: u64, + err: Error, +} + +impl PartialEq for Refusal { + fn eq(&self, other: &Self) -> bool { + self.id == other.id } } +impl Eq for Refusal {} + /// A reader's request: the run `start.group..end`, from `start.frame` in its first group. pub(super) struct Ask { pub start: Position, @@ -469,12 +483,14 @@ impl Fill { /// since it was asked. `None` once every group is answered. pub fn next(&self) -> Option { loop { - let resolved = self.inner.result.read().resolved(); let span = self.inner.start.group..self.inner.end; - let mut gaps = resolved.gaps(&span); - let sequence = match self.inner.order { - Order::Ascending => gaps.next().map(|gap| gap.start), - Order::Descending => gaps.last().map(|gap| gap.end - 1), + let sequence = { + let outcome = self.inner.result.read(); + let mut gaps = outcome.resolved.gaps(&span); + match self.inner.order { + Order::Ascending => gaps.next().map(|gap| gap.start), + Order::Descending => gaps.last().map(|gap| gap.end - 1), + } }?; // The live feed may have delivered it since: answered by the cache, not the source. let cached = self @@ -489,7 +505,7 @@ impl Fill { let Ok(mut outcome) = self.inner.result.write() else { return Some(sequence); }; - outcome.accepted.insert(sequence..sequence + 1); + outcome.accept(sequence..sequence + 1); drop(outcome); uncover(&self.inner.fills, self.inner.id, sequence..sequence + 1); } @@ -536,14 +552,14 @@ impl Inner { let Some(end) = sequence.checked_add(1) else { return Err(Error::InvalidRange); }; - if self.result.read().resolved().contains(&sequence) { + if self.result.read().resolved.contains(&sequence) { return Err(Error::Duplicate); } // Cache the group before answering: readers resolve an accept through the cache. let res = TrackState::modify(&self.state) .and_then(|mut state| state.insert_group_request(sequence, self.frame_start(sequence), info)); if let Ok(mut outcome) = self.result.write() { - outcome.accepted.insert(sequence..end); + outcome.accept(sequence..end); } uncover(&self.fills, self.id, sequence..end); res @@ -570,8 +586,7 @@ impl Inner { /// The part of `groups` nothing answered yet. fn unresolved(&self, groups: ops::Range) -> RangeSet { - let resolved = self.result.read().resolved(); - resolved.gaps(&groups).collect() + self.result.read().resolved.gaps(&groups).collect() } fn refuse(&self, groups: &RangeSet, err: Error) { From 42af07c78d3cb43b527766d551fdf6c87c3244b4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 14:06:04 -0700 Subject: [PATCH 16/48] fix(net): a reader from an earlier group widens a queued fill to its head Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 20 ++++++++++++++++++++ rs/moq-net/src/model/track/fill.rs | 9 +++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index d16c040cb8d..8ea3bb449e9 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15247,6 +15247,26 @@ mod test { assert_eq!(fill.next(), Some(WIDE - REFUSED - 1)); } + /// A reader starting in an earlier group widens a queued fill to the whole of its first + /// group, not just one starting where it does. + #[test] + fn an_earlier_reader_widens_a_queued_fill_to_its_head() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let start = Anchor::Absolute(Position { group: 1, frame: 7 }); + let mut mid = producer.subscribe(replay().with_ranges([Range { start, end: None }])); + let mut whole = producer.subscribe(replay().with_groups(0..)); + producer.start_at(4).unwrap(); + write_group(&producer, 4); + let _ = mid.poll_recv_group(&kio::Waiter::noop()); + let _ = whole.poll_recv_group(&kio::Waiter::noop()); + + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) else { + panic!("the miss below the start is asked"); + }; + assert_eq!((fill.start(), fill.end()), (Position { group: 1, frame: 0 }, 4)); + } + /// A descending reader joining a queued fill makes it newest first, as any descending /// subscriber does a subscription. #[test] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index e8aa64c7dd3..29863ee3093 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -233,8 +233,13 @@ impl FillState { if ask.order == Order::Descending { pending.order = Order::Descending; } - if groups.start == pending.start.group && ask.start.group == groups.start { - pending.start.frame = pending.start.frame.min(ask.start.frame); + // A reader starting in an earlier group wants this one whole. + if groups.start == pending.start.group { + let frame = match ask.start.group == groups.start { + true => ask.start.frame, + false => 0, + }; + pending.start.frame = pending.start.frame.min(frame); } } joins.push(Join { From bf30e3883d96fee7265f722f37356184bf598117 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 14:12:39 -0700 Subject: [PATCH 17/48] quest: refill groups evicted after a reader judged them A reader judges each sequence once, so a group evicted after it was judged cached is skipped rather than filled. Say so in the docs, and plan the refill with per-reader eviction tracking and a thrash guard. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 8 ++++--- quest/m2/README.md | 1 + quest/m2/evicted-refill.md | 36 ++++++++++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 3 +++ 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 quest/m2/evicted-refill.md diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index bf127e3a3f9..1af1e40f38b 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -249,10 +249,12 @@ Ranges are both a request and a filter. A subscriber reads only the groups its own ranges cover, resolved again whenever it replaces them, even when another subscriber of the same track asked for more. Widening them, including lowering the start, also serves the cached groups they newly cover, never one already -served. A group they cover below the live feed that the cache lacks (evicted, -skipped, or below where the feed starts) is asked of the track's dynamic handler +served. A group they cover below the live feed that the cache lacks (evicted +before the subscriber first looked for it, skipped, or below where the feed +starts) is asked of the track's dynamic handler as a fill, which a relay serves by fetching upstream one group at a time; with -no handler it is reported as dropped once the feed stops waiting for it. A relay merges its subscribers' ranges into one upstream request, which +no handler it is reported as dropped once the feed stops waiting for it. One +evicted after the subscriber saw it cached, but before it read it, is skipped. A relay merges its subscribers' ranges into one upstream request, which today's wire versions carry as the single span covering them. A time anchor is resolved from the groups the serving hop holds; upstream it asks for everything max delay allows, and an untimed track refuses it. At the live edge, a time end diff --git a/quest/m2/README.md b/quest/m2/README.md index 7f161f908cd..79b8c7923ca 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -68,6 +68,7 @@ ranked just below deterministic TS legs, which it requires. - [Ordered owed wait](/quest/m2/ordered-owed-wait.md) - an ordered reader waits within max_delay for a missing group only while the track still owes it - [lite-05/06 gap judgement](/quest/m2/lite-legacy-gap.md) - a resumed copy on lite-05/06 never reads a descending track's newest group as its start - [Unbounded groups](/quest/m2/unbounded-groups/README.md) - groups and frames lose their size caps; the cache sheds parts of a group under pressure and refills the holes in place +- [Refill evicted groups](/quest/m2/evicted-refill.md) - a group evicted after a reader judged it but before it took it is refilled, never delivered twice, and guarded against thrash - [Rust-backed @moq/net](/quest/m2/rs2ts/README.md) - measure browser baselines, complete WASM bindings, and adopt Rust behind @moq/net before the later JS-only stage - [Front parking](/quest/m2/origin-front-parks.md) - an unroutable request waits on a front instead of re-asking on every route-table move - [Route wakes](/quest/m2/route-wakes.md) - a route change wakes only the fronts it can move, so pool churn stops scaling with served paths diff --git a/quest/m2/evicted-refill.md b/quest/m2/evicted-refill.md new file mode 100644 index 00000000000..720891ce1fa --- /dev/null +++ b/quest/m2/evicted-refill.md @@ -0,0 +1,36 @@ +# [M] Refill groups evicted before a reader took them + +## Goal + +A reader behind the live edge still gets a group the cache evicted after the +reader judged it but before it took it: the group becomes a miss and is asked +of the track's fills, like one evicted before the reader judged it. A reader +never receives a group it already took twice, and slow readers under cache +pressure cannot make the relay refetch without bound. + +## Plan + +From [moq-dev/moq#6171](https://github.com/moq-dev/moq/pull/6171), where the +maintainer chose to land fills without this (2026-10-21). Today a reader +judges each sequence once: a group cached when judged and evicted later is +skipped silently. + +- Track evictions per reader. An ordered reader can judge again every + eviction at or above its next sequence. An arrival-order reader delivers + above its floor, so it must tell the groups it already walked past from + the ones still ahead, for example by keeping each evicted group's arrival + index. Bound what the track keeps by the slowest reader, not by the cache. +- Guard against thrash. Under pool pressure a refill evicts another group, + possibly the next one the same slow reader needs. Decide in the PR, with + options and a recommendation, how a reader stops refilling once its + refills only evict what it reads next: give up to the drop cursor, or rely + on its max delay. +- Test a slow ascending reader on a pooled track under bufferbloat: it + receives every group its budget covers, takes none twice, and refills stay + proportional to what it reads. + +Public API: none. Wire: none. + +## Related + +- [Unbounded groups](/quest/m2/unbounded-groups/README.md) - refills the parts of a group the cache sheds under pressure diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 2f90e768387..fc1d5233071 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -7,6 +7,9 @@ //! (evicted, or a gap the feed skipped). A hole the ledger still waits for is deferred and //! judged again as the feed moves. //! +//! Each sequence is judged once, so a group evicted after the reader judged it cached is +//! not a miss: judging it again would refill a group an arrival-order reader already took. +//! //! A start is no promise, since a group below it may still arrive late. A hole under it is //! asked at once, but a refusal is final only once the ledger gives up on it too; until //! then the live feed may still deliver it. From f0e2b60021463f9c297232db6bd14bcff49b6daf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 20:54:27 -0700 Subject: [PATCH 18/48] quest: claim archive consumer Co-Authored-By: Claude Opus 5.5 From 9fa29791f8f432813ae6384c75246f0e7d3cec9a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 21:35:11 -0700 Subject: [PATCH 19/48] fix(net): hand filled groups out in the reader's order A fill answering several groups before the reader came back handed them out lowest first, so a descending reader with a warm cache read history oldest first. Pick the next filled group in the subscription's order. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 37 ++++++++++++++++++++++++++++-- rs/moq-net/src/model/track/miss.rs | 18 +++++++++++---- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 0435cd8f403..03bc400ed60 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5848,11 +5848,13 @@ impl Cursor { } } - /// The lowest group a fill answered for this cursor, cached and not handed out yet. + /// The next group a fill answered for this cursor in its order, cached and not handed + /// out yet. fn next_filled(&mut self) -> Option { let misses = self.misses.as_mut()?; + let order = self.subscription.read().order; let mut given_up = Vec::new(); - let group = misses.next_filled(&self.state.read(), &mut given_up); + let group = misses.next_filled(&self.state.read(), order, &mut given_up); if let Some(drops) = &mut self.drops { for run in given_up { drops.give_up(run); @@ -15353,6 +15355,37 @@ mod test { ); } + /// Groups a fill answered before the reader came back are handed out in the reader's + /// order, so a warm cache keeps a descending reader newest first. + #[test] + #[cfg(feature = "async")] + fn filled_groups_are_read_in_the_readers_order() { + for order in [Order::Descending, Order::Ascending] { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_groups(0..).with_order(order)); + producer.start_at(2).unwrap(); + write_group(&producer, 2); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + while let Some(sequence) = fill.next() { + write_filled(fill.accept(sequence, None), sequence); + } + let mut read = Vec::new(); + while let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) { + read.push(group.sequence); + } + let expected = match order { + Order::Descending => [1, 0], + Order::Ascending => [0, 1], + }; + assert_eq!(read, expected, "{order:?}"); + } + } + /// A group the handler refused that still arrives live is delivered. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 9dc821966be..beeaf24d704 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -91,16 +91,26 @@ impl Misses { &self.given_up } - /// The lowest answered group still cached, forgetting one evicted before it was read. - pub fn next_filled(&mut self, state: &TrackState, given_up: &mut GivenUp) -> Option { - while let Some(&sequence) = self.filled.first() { + /// The next answered group still cached in `order`, forgetting one evicted before it was + /// read. + pub fn next_filled( + &mut self, + state: &TrackState, + order: Order, + given_up: &mut GivenUp, + ) -> Option { + loop { + let sequence = match order { + Order::Ascending => self.filled.first(), + Order::Descending => self.filled.last(), + } + .copied()?; if let Some(slot) = state.lookup.get(&sequence).filter(|slot| !slot.is_aborted()) { return Some(slot.serving().clone()); } self.filled.remove(&sequence); self.give_up(sequence..sequence + 1, given_up); } - None } /// Take `sequence` as handed out. From 074d512815f2664bfc3877bb9592065b038364e5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 21:40:03 -0700 Subject: [PATCH 20/48] feat(archive): serve an archive through archive::Consumer, replacing Reader Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 - rs/moq-archive/Cargo.toml | 5 +- rs/moq-archive/src/consumer/index.rs | 134 +++++ rs/moq-archive/src/consumer/mod.rs | 354 +++++++++++ rs/moq-archive/src/consumer/tests.rs | 557 +++++++++++++++++ rs/moq-archive/src/consumer/track.rs | 278 +++++++++ rs/moq-archive/src/error.rs | 16 +- rs/moq-archive/src/info.rs | 39 +- rs/moq-archive/src/layout.rs | 19 +- rs/moq-archive/src/lib.rs | 16 +- rs/moq-archive/src/mock.rs | 31 - rs/moq-archive/src/path.rs | 210 +------ rs/moq-archive/src/producer/track.rs | 3 +- rs/moq-archive/src/reader/index.rs | 170 ------ rs/moq-archive/src/reader/mod.rs | 684 --------------------- rs/moq-archive/src/reader/tests.rs | 868 --------------------------- rs/moq-archive/src/segment.rs | 626 ------------------- rs/moq-archive/src/store.rs | 763 +---------------------- 18 files changed, 1382 insertions(+), 3393 deletions(-) create mode 100644 rs/moq-archive/src/consumer/index.rs create mode 100644 rs/moq-archive/src/consumer/mod.rs create mode 100644 rs/moq-archive/src/consumer/tests.rs create mode 100644 rs/moq-archive/src/consumer/track.rs delete mode 100644 rs/moq-archive/src/reader/index.rs delete mode 100644 rs/moq-archive/src/reader/mod.rs delete mode 100644 rs/moq-archive/src/reader/tests.rs delete mode 100644 rs/moq-archive/src/segment.rs diff --git a/Cargo.lock b/Cargo.lock index 584ede7c50d..7d03cbb3e3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4534,10 +4534,8 @@ dependencies = [ "bytes", "criterion", "futures", - "hang", "kio 0.6.2", "moq-clock", - "moq-json", "moq-net", "object_store", "percent-encoding", diff --git a/rs/moq-archive/Cargo.toml b/rs/moq-archive/Cargo.toml index a0b47661cbc..9b4db900768 100644 --- a/rs/moq-archive/Cargo.toml +++ b/rs/moq-archive/Cargo.toml @@ -15,10 +15,8 @@ categories = ["multimedia", "filesystem"] [dependencies] bytes = { workspace = true } futures = { workspace = true } -hang = { workspace = true } kio = { workspace = true } moq-clock = { workspace = true } -moq-json = { workspace = true } moq-net = { workspace = true } object_store = { workspace = true } percent-encoding = { workspace = true } @@ -26,7 +24,6 @@ quick_cache = "0.7" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["macros", "sync", "time"] } tracing = { workspace = true } [dev-dependencies] @@ -37,7 +34,7 @@ object_store = { workspace = true, features = ["fs", "aws"] } s3s = "0.17" s3s-fs = "0.17" tempfile = { workspace = true } -tokio = { workspace = true, features = ["macros", "net", "rt", "test-util"] } +tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "test-util", "time"] } [[bench]] name = "producer" diff --git a/rs/moq-archive/src/consumer/index.rs b/rs/moq-archive/src/consumer/index.rs new file mode 100644 index 00000000000..790744cba9d --- /dev/null +++ b/rs/moq-archive/src/consumer/index.rs @@ -0,0 +1,134 @@ +//! One track's object keys, listed once and extended after its newest key. + +use std::collections::BTreeMap; + +use futures::TryStreamExt; +use moq_net::Timescale; +use object_store::path::Path; +use object_store::{ObjectStore, ObjectStoreExt}; + +use crate::layout::{self, End, Name}; +use crate::{Error, Info, Result}; + +/// Where an object starts: its first sequence and that sequence's first frame index. +pub(super) type Start = (u64, u64); + +/// What one listing of a track's directory found. +#[derive(Default)] +pub(super) struct Listed { + /// The objects new since the cursor, oldest first. + pub objects: Vec<(Start, Path)>, + /// The track's `end`, when asked for. + pub end: Option, +} + +/// A track's stored objects, by where each starts. +pub(super) struct Index { + pub dir: Path, + pub timescale: Option, + objects: BTreeMap, + /// The newest key listed, which the next listing starts after. + cursor: Path, +} + +impl Index { + pub fn new(dir: Path, info: &Info) -> Result { + let cursor = crate::path::push(&dir, layout::TRACK_INFO)?; + Ok(Self { + dir, + timescale: super::track::timescale(info)?, + objects: BTreeMap::new(), + cursor, + }) + } + + pub fn timed(&self) -> bool { + self.timescale.is_some() + } + + /// The newest key listed, for the next listing to start after. + pub fn cursor(&self) -> &Path { + &self.cursor + } + + /// Take in the objects a listing after [`Self::cursor`] found. + pub fn extend(&mut self, objects: &[(Start, Path)]) { + for (start, path) in objects { + self.objects.insert(*start, path.clone()); + self.cursor = path.clone(); + } + } + + /// The newest object, which holds the newest stored sequence. + pub fn newest(&self) -> Option<(Start, Path)> { + self.objects.last_key_value().map(|(start, path)| (*start, path.clone())) + } + + /// The objects holding group `sequence` from frame `frame` on, oldest first: the last one + /// starting at or before it, then each continuing the group. Also the start of the first + /// object after them, which bounds a gap. + pub fn holding(&self, sequence: u64, frame: u64) -> (Vec<(Start, Path)>, Option) { + let mut holding = Vec::new(); + if let Some((start, path)) = self.objects.range(..=(sequence, frame)).next_back() { + holding.push((*start, path.clone())); + } + let mut after = None; + for (start, path) in self.objects.range((sequence, frame.saturating_add(1))..) { + if start.0 != sequence { + after = Some(*start); + break; + } + holding.push((*start, path.clone())); + } + (holding, after) + } + + /// Forget an object the store no longer has, such as one a DVR pruned. + pub fn forget(&mut self, start: Start) { + self.objects.remove(&start); + } +} + +/// List a track's directory after `cursor`, refusing anything the layout does not name, and +/// read its `end` when `end` is set. `.info` sorts before every object key and `end` after, +/// so listing after `.info` lists everything else. Object stores promise no listing order, so +/// it is sorted here. +pub(super) async fn list( + store: &S, + dir: &Path, + cursor: &Path, + timed: bool, + end: bool, +) -> Result { + let metas: Vec<_> = store.list_with_offset(Some(dir), cursor).try_collect().await?; + let mut listed = Listed::default(); + let mut found = None; + for meta in metas { + // A listing is recursive; anything deeper than the track's own entries is foreign. + if meta.location.prefix_match(dir).is_none_or(|parts| parts.count() != 1) { + return Err(Error::Path(meta.location.to_string())); + } + let filename = meta + .location + .filename() + .ok_or_else(|| Error::Path(meta.location.to_string()))?; + match Name::parse(filename, timed)? { + Name::Info => {} + Name::End => found = Some(meta.location), + Name::Object(start) => listed.objects.push(((start.group, start.frame), meta.location)), + } + } + listed.objects.sort(); + if end { + // The producer writes every track's `end` before the broadcast's `.end`. + let path = found.ok_or_else(|| Error::NotFound(format!("{dir}/{}", layout::TRACK_END)))?; + listed.end = Some(End::decode(&store.get(&path).await?.bytes().await?)?); + } + Ok(listed) +} + +/// A track's `.info`. +pub(super) async fn info(store: &S, dir: &Path) -> Result { + let path = crate::path::push(dir, layout::TRACK_INFO)?; + Info::decode(&store.get(&path).await?.bytes().await?) +} diff --git a/rs/moq-archive/src/consumer/mod.rs b/rs/moq-archive/src/consumer/mod.rs new file mode 100644 index 00000000000..ecceff7b141 --- /dev/null +++ b/rs/moq-archive/src/consumer/mod.rs @@ -0,0 +1,354 @@ +//! Serve one broadcast's archive as an ordinary broadcast. +//! +//! A [`Consumer`] lists a [`Store`] prefix a [`Producer`](crate::Producer) wrote and creates every +//! stored track on a `moq_net` broadcast, the archived catalog included, with its stored +//! timescale and priority and no max age: the store's retention bounds what it holds. Each +//! track's live feed starts at its newest stored sequence, and every older group is served on +//! demand from the store through [`track::Dynamic`] fills, each sequence in its recorded form: a +//! group as a group, a datagram as a datagram. +//! +//! A growing archive is followed: every [`Config::refresh`], one delimiter LIST of the prefix +//! finds added tracks, and one LIST per track after its newest key finds new objects. Once the +//! prefix holds `.end`, each track ends as recorded and refreshing stops. Without it the archive +//! stays live, which is also how one whose producer stopped early reads. +//! +//! ```no_run +//! # async fn example(broadcast: moq_net::broadcast::Producer) -> moq_archive::Result<()> { +//! use moq_archive::object_store::memory::InMemory; +//! +//! let store = moq_archive::Store::new(InMemory::new(), "recordings/demo"); +//! let consumer = moq_archive::Consumer::open(store, broadcast, Default::default()).await?; +//! // Every stored track exists now: announce the broadcast, then serve it. +//! consumer.run().await?; +//! # Ok(()) +//! # } +//! ``` + +mod index; +mod track; + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::task::Poll; +use std::time::Duration; + +use futures::FutureExt; +use futures::future::BoxFuture; +use moq_net::broadcast; +use object_store::{ObjectStore, ObjectStoreExt}; +use object_store::path::Path; + +use crate::layout::{self, End, Object}; +use crate::{Error, Result, Store}; +use index::{Index, Start}; + +/// How a [`Consumer`] follows and caches its archive. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct Config { + /// How often a growing archive is listed for new objects and tracks. + pub refresh: Duration, + /// The most object bytes kept in memory, so neighbouring groups come from one GET. + pub cache: u64, +} + +impl Default for Config { + fn default() -> Self { + Self { + refresh: Duration::from_secs(1), + cache: 64 * 1024 * 1024, + } + } +} + +impl Config { + /// Set [`refresh`](Self::refresh). + pub fn with_refresh(mut self, refresh: Duration) -> Self { + self.refresh = refresh; + self + } + + /// Set [`cache`](Self::cache). + pub fn with_cache(mut self, bytes: u64) -> Self { + self.cache = bytes; + self + } +} + +/// Serves one archived broadcast until the broadcast closes. +pub struct Consumer { + shared: Arc>, + broadcast: broadcast::Producer, + /// Answers requests for names the archive does not hold, and says when the broadcast closed. + requests: broadcast::Dynamic, + refresh: Duration, + tracks: HashMap, + /// Whether the prefix held `.end`, so every track ended and nothing is refreshed. + ended: bool, +} + +struct Shared { + store: Store, + cache: quick_cache::sync::Cache, Weight>, +} + +struct Track { + index: Arc>, + live: track::Live, + dynamic: moq_net::track::Dynamic, +} + +/// What one refresh found, applied to the served tracks at once. +#[derive(Default)] +struct Refreshed { + /// Tracks new since the last refresh, with their index. + added: Vec<(String, moq_net::track::Info, Arc>)>, + /// Each track's new objects, fed live from the sequence given. + pushes: Vec<(String, Vec>, u64)>, + /// Each track's end, once the prefix holds `.end`. + ends: Option>, +} + +/// A track as a refresh starts: where its listing resumes and whether its feed started. +struct Snapshot { + name: String, + index: Arc>, + started: bool, +} + +impl Consumer { + /// List `store`'s prefix and create every stored track on `broadcast`, so a host can announce + /// it with every track listed. + /// + /// Fails with [`Error::NotFound`] when the prefix holds no track, and with [`Error::Path`] + /// when it holds a key the layout does not name. + pub async fn open(store: Store, broadcast: broadcast::Producer, config: Config) -> Result { + let shared = Arc::new(Shared { + store, + cache: quick_cache::sync::Cache::with_weighter( + // Estimated item count; the byte capacity below is the real bound. + 1024, + config.cache, + Weight, + ), + }); + let mut consumer = Self { + shared, + requests: broadcast.dynamic(), + broadcast, + refresh: config.refresh, + tracks: HashMap::new(), + ended: false, + }; + let refreshed = refresh(consumer.shared.clone(), Vec::new()).await?; + if refreshed.added.is_empty() { + return Err(Error::NotFound(consumer.shared.store.prefix().to_string())); + } + consumer.apply(refreshed)?; + Ok(consumer) + } + + /// Serve the archive until the broadcast closes, following it while it grows. + /// + /// Fails when the store does, or when it holds a key or object the layout does not name. + pub async fn run(mut self) -> Result<()> { + let mut fills = kio::Tasks::new(); + let mut refreshing: Option>> = None; + + enum Event { + Refreshed(Result), + Closed, + } + + loop { + if refreshing.is_none() && !self.ended { + let snapshots = self.snapshots(); + let (shared, period) = (self.shared.clone(), self.refresh); + refreshing = Some( + async move { + moq_clock::sleep(period).await; + refresh(shared, snapshots).await + } + .boxed(), + ); + } + + let event = kio::wait(|waiter| { + for track in self.tracks.values() { + while let Poll::Ready(Ok(fill)) = track.dynamic.poll_requested_fill(waiter) { + let serve = track::serve(self.shared.clone(), track.index.clone(), fill); + fills.push(task(serve.boxed())); + } + } + let _ = fills.poll(waiter); + if let Some(future) = refreshing.as_mut() + && let Poll::Ready(refreshed) = waiter.poll_future(future.as_mut()) + { + return Poll::Ready(Event::Refreshed(refreshed)); + } + loop { + match self.requests.poll_requested_track(waiter) { + // Every stored track already exists, so the archive holds no such name. + Poll::Ready(Ok(request)) => request.reject(moq_net::Error::NotFound), + Poll::Ready(Err(_)) => return Poll::Ready(Event::Closed), + Poll::Pending => return Poll::Pending, + } + } + }) + .await; + + match event { + Event::Refreshed(refreshed) => { + refreshing = None; + self.apply(refreshed?)?; + } + Event::Closed => return Ok(()), + } + } + } + + fn snapshots(&self) -> Vec { + self.tracks + .iter() + .map(|(name, track)| Snapshot { + name: name.clone(), + index: track.index.clone(), + started: track.live.is_started(), + }) + .collect() + } + + fn apply(&mut self, refreshed: Refreshed) -> Result<()> { + for (name, info, index) in refreshed.added { + let producer = self.broadcast.create_track(name.as_str(), info)?; + let dynamic = producer.dynamic(); + self.tracks.insert( + name, + Track { + index, + live: track::Live::new(producer), + dynamic, + }, + ); + } + for (name, objects, from) in refreshed.pushes { + if let Some(track) = self.tracks.get_mut(&name) { + track.live.push(&objects, from)?; + } + } + if let Some(ends) = refreshed.ends { + for (name, end) in ends { + if let Some(track) = self.tracks.get_mut(&name) { + track.live.end(end, &name)?; + } + } + self.ended = true; + } + Ok(()) + } +} + +/// List the prefix for added tracks and `.end`, then each track after its newest key, and GET +/// what each track's live feed takes next. +async fn refresh(shared: Arc>, mut tracks: Vec) -> Result { + let store = &shared.store; + let prefix = store.prefix(); + let scope = (!prefix.as_ref().is_empty()).then_some(prefix); + let listed = store.inner().list_with_delimiter(scope).await?; + + let mut ended = false; + for object in &listed.objects { + match object.location.filename() { + Some(layout::BROADCAST_END) => ended = true, + _ => return Err(Error::Path(object.location.to_string())), + } + } + + let mut refreshed = Refreshed::default(); + for dir in listed.common_prefixes { + let segment = dir.filename().ok_or_else(|| Error::Path(dir.to_string()))?; + let name = layout::parse_track(segment)?; + if tracks.iter().any(|track| track.name == name) { + continue; + } + let stored = index::info(store.inner(), &dir).await?; + let info = track::info(&stored)?; + let index = Arc::new(Mutex::new(Index::new(dir, &stored)?)); + refreshed.added.push((name.clone(), info, index.clone())); + tracks.push(Snapshot { + name, + index, + started: false, + }); + } + + let mut ends = Vec::new(); + for track in tracks { + let (dir, cursor, timed) = { + let index = track.index.lock().unwrap(); + (index.dir.clone(), index.cursor().clone(), index.timed()) + }; + let listed = index::list(store.inner(), &dir, &cursor, timed, ended).await?; + track.index.lock().unwrap().extend(&listed.objects); + if let Some(end) = listed.end { + ends.push((track.name.clone(), end)); + } + + let (objects, from) = match track.started { + // Everything new, in order. + true => (listed.objects, 0), + // Only the newest sequence: fills serve the rest. + false => { + let Some((start, path)) = track.index.lock().unwrap().newest() else { + continue; + }; + let newest = object(&shared, &path, start, timed).await?; + let sequence = newest.entries.last().ok_or(Error::Empty)?.sequence(); + (track.index.lock().unwrap().holding(sequence, 0).0, sequence) + } + }; + let mut fetched = Vec::with_capacity(objects.len()); + for (start, path) in objects { + fetched.push(object(&shared, &path, start, timed).await?); + } + if !fetched.is_empty() { + refreshed.pushes.push((track.name, fetched, from)); + } + } + refreshed.ends = ended.then_some(ends); + Ok(refreshed) +} + +/// The object at `path`, from the cache or one GET, checked against the key it is listed under. +async fn object(shared: &Shared, path: &Path, start: Start, timed: bool) -> Result> { + shared + .cache + .get_or_insert_async(path, async { + let bytes = shared.store.inner().get(path).await?.bytes().await?; + let object = Object::decode(bytes, timed)?; + let found = object.start()?; + if (found.group, found.frame) != start { + return Err(Error::Path(path.to_string())); + } + Ok(Arc::new(object)) + }) + .await +} + +/// Adapt a boxed future into a [`kio::Tasks`] task. +fn task(mut future: BoxFuture<'static, ()>) -> impl FnMut(&kio::Waiter) -> Poll<()> + Send { + move |waiter| waiter.poll_future(future.as_mut()) +} + +/// Weighs a cached object by its payload bytes plus a small per-frame overhead. +#[derive(Clone)] +struct Weight; + +impl quick_cache::Weighter> for Weight { + fn weight(&self, _path: &Path, object: &Arc) -> u64 { + object.weight() + } +} diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs new file mode 100644 index 00000000000..72a74975f6c --- /dev/null +++ b/rs/moq-archive/src/consumer/tests.rs @@ -0,0 +1,557 @@ +use std::time::Duration; + +use bytes::Bytes; +use moq_clock::{sleep, timeout}; +use moq_net::track::{Order, Position, Subscription}; +use moq_net::{Timescale, Timestamp, broadcast, group, track}; +use object_store::ObjectStoreExt; +use tokio::task::JoinHandle; + +use super::*; +use crate::Info; +use crate::layout::{At, Entry, Frame}; +use crate::mock::Mock; + +const REFRESH: Duration = Duration::from_secs(1); + +/// Writes a prefix the way a [`crate::Producer`] lays it out, object by object. +struct Archive { + store: Store, +} + +impl Archive { + fn new() -> Self { + Self { + store: Store::new(Mock::memory(), "rec"), + } + } + + /// A timed track, timescale 1000, or an untimed one. + async fn track(&self, track: &str, priority: u8, timed: bool) { + let info = Info::new(priority, timed.then_some(1000)).unwrap(); + self.store.put_info(track, &info).await.unwrap(); + } + + /// Store one object whose first entry starts at `frame_start`. + async fn object(&self, track: &str, frame_start: u64, entries: Vec) { + let timed = self.store.get_info(track).await.unwrap().timescale.is_some(); + let object = Object { frame_start, entries }; + let name = object.start().unwrap().name().unwrap(); + let path = crate::path::push(&self.dir(track), &name).unwrap(); + let bytes = object.encode(timed).unwrap(); + self.store.inner().put(&path, bytes.into()).await.unwrap(); + } + + async fn end(&self, track: &str, end: End) { + let path = crate::path::push(&self.dir(track), layout::TRACK_END).unwrap(); + let bytes = end.encode().unwrap(); + self.store.inner().put(&path, bytes.into()).await.unwrap(); + } + + /// Write the broadcast's `.end`, after every track's `end`. + async fn complete(&self) { + self.raw(layout::BROADCAST_END, b"").await; + } + + async fn raw(&self, key: &str, bytes: &'static [u8]) { + let path = object_store::path::Path::parse(format!("rec/{key}")).unwrap(); + self.store + .inner() + .put(&path, Bytes::from_static(bytes).into()) + .await + .unwrap(); + } + + fn dir(&self, track: &str) -> object_store::path::Path { + layout::track_dir(self.store.prefix(), track).unwrap() + } +} + +/// Frame `index` of `sequence`, its timestamp and payload naming both. +fn frame(timed: bool, sequence: u64, index: u64) -> Frame { + Frame { + timestamp: timed.then_some(sequence * 1000 + index), + payload: Bytes::from(format!("{sequence}/{index}")), + } +} + +/// Group `sequence`'s frames `frames`. +fn frames(timed: bool, sequence: u64, frames: std::ops::Range) -> Entry { + Entry::Group { + sequence, + frames: frames.map(|index| frame(timed, sequence, index)).collect(), + } +} + +fn datagram(timed: bool, sequence: u64) -> Entry { + Entry::Datagram { + sequence, + frame: frame(timed, sequence, 0), + } +} + +/// What a reader sees of `frames(timed, sequence, indices)`. +fn expected(timed: bool, sequence: u64, indices: std::ops::Range) -> Vec { + indices.map(|index| describe(&frame(timed, sequence, index))).collect() +} + +fn describe(frame: &Frame) -> String { + let payload = String::from_utf8(frame.payload.to_vec()).unwrap(); + match frame.timestamp { + Some(pts) => format!("{pts}@{payload}"), + None => payload, + } +} + +fn read(timestamp: Option, payload: Bytes) -> String { + describe(&Frame { + timestamp: timestamp.map(Timestamp::value), + payload, + }) +} + +async fn serve(store: &Store) -> (broadcast::Producer, JoinHandle>) { + let broadcast = broadcast::Info::new().produce(); + let config = Config::default().with_refresh(REFRESH); + let consumer = Consumer::open(store.clone(), broadcast.clone(), config).await.unwrap(); + (broadcast, tokio::spawn(consumer.run())) +} + +/// FETCH group `sequence` from frame `frame_start` on. +async fn fetch( + broadcast: &broadcast::Producer, + track: &str, + sequence: u64, + frame_start: u64, +) -> moq_net::Result> { + let track = broadcast.consume().track(track)?; + let fetch = group::Fetch::default().with_frame_start(frame_start); + let mut group = timeout(Duration::from_secs(10), track.fetch_group(sequence, fetch)) + .await + .expect("the fetch is answered")?; + let mut frames = Vec::new(); + while let Some(frame) = timeout(Duration::from_secs(10), group.read_frame()) + .await + .expect("the group ends")? + { + frames.push(read(frame.timestamp, frame.payload)); + } + Ok(frames) +} + +/// Read every group from 0 on, in order, with no age limit. +async fn subscribe(broadcast: &broadcast::Producer, track: &str) -> track::Ordered { + let track = broadcast.consume().track(track).unwrap(); + let subscription = Subscription::default() + .with_groups(0..) + .with_order(Order::Ascending) + .with_max_delay(None); + track.subscribe(subscription).await.unwrap().ordered() +} + +/// The next group's sequence, without waiting for it to end. +async fn sequence(reader: &mut track::Ordered) -> Option { + timeout(Duration::from_secs(10), reader.next_group()) + .await + .expect("a group arrives") + .unwrap() + .map(|group| group.sequence) +} + +/// The next group and every frame in it, once the group ends. +async fn next(reader: &mut track::Ordered) -> Option<(u64, Vec)> { + let mut group = timeout(Duration::from_secs(10), reader.next_group()) + .await + .expect("a group arrives") + .unwrap()?; + let mut frames = Vec::new(); + while let Some(frame) = timeout(Duration::from_secs(10), group.read_frame()) + .await + .expect("the group ends") + .unwrap() + { + frames.push(read(frame.timestamp, frame.payload)); + } + Some((group.sequence, frames)) +} + +fn not_found(result: moq_net::Result>) { + assert!(matches!(result, Err(moq_net::Error::NotFound)), "{result:?}"); +} + +/// Recorded through the real producer, served back with every group, frame, form, and +/// property it had, byte for byte. +#[tokio::test(start_paused = true)] +async fn a_recording_round_trips() { + let store = Store::new(Mock::memory(), "rec"); + let source = broadcast::Info::new().produce(); + // The source keeps a second: the archive outlives it. + let timed = track::Info::default() + .with_timescale(Timescale::MILLI) + .with_priority(3) + .with_max_age(Duration::from_secs(1)); + let video = source.create_track("video", timed).unwrap(); + let mut log = source.create_track("log", track::Info::default().with_priority(9)).unwrap(); + let config = crate::producer::Config::default() + .with_duration(Duration::from_secs(2)) + .with_flush(Duration::from_secs(100)); + let recording = tokio::spawn(crate::Producer::new(store.clone(), source.consume(), config).run()); + while video.subscription().is_none() || log.subscription().is_none() { + sleep(Duration::from_millis(1)).await; + } + for sequence in 0..6 { + let mut group = video.create_group(group::Info { sequence }).unwrap(); + for index in 0..3 { + let pts = Timestamp::from_millis(sequence * 10_000 + index).unwrap(); + group.write_frame(pts, Bytes::from(vec![sequence as u8, index as u8, 0xff])).unwrap(); + } + group.finish().unwrap(); + sleep(Duration::from_millis(10)).await; + } + let mut group = log.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(None, "a").unwrap(); + group.finish().unwrap(); + sleep(Duration::from_millis(10)).await; + log.insert_datagram(1, None, "b").unwrap(); + sleep(Duration::from_millis(10)).await; + video.finish().unwrap(); + log.finish().unwrap(); + sleep(Duration::from_millis(10)).await; + source.close(); + recording.await.unwrap().unwrap(); + + let (broadcast, _run) = serve(&store).await; + let served = broadcast.consume().track("video").unwrap(); + let info = served.query().await.unwrap(); + assert_eq!(info.priority, 3); + assert_eq!(info.timescale, Some(Timescale::MILLI)); + assert_eq!(info.max_age, None); + + let mut reader = subscribe(&broadcast, "video").await; + for sequence in 0..6 { + let mut group = reader.next_group().await.unwrap().unwrap(); + assert_eq!(group.sequence, sequence); + for index in 0..3 { + let frame = group.read_frame().await.unwrap().unwrap(); + assert_eq!(frame.timestamp.unwrap().value(), sequence * 10_000 + index); + assert_eq!(&frame.payload[..], &[sequence as u8, index as u8, 0xff]); + } + assert!(group.read_frame().await.unwrap().is_none()); + } + assert!(reader.next_group().await.unwrap().is_none()); + assert_eq!(reader.finished().await.unwrap(), Position::group(6)); + + // The untimed track keeps its datagram a datagram. + let info = broadcast.consume().track("log").unwrap().query().await.unwrap(); + assert_eq!((info.priority, info.timescale), (9, None)); + assert_eq!(fetch(&broadcast, "log", 0, 0).await.unwrap(), ["a"]); + assert!(matches!( + fetch(&broadcast, "log", 1, 0).await, + Err(moq_net::Error::NotFetchable | moq_net::Error::NotFound) + )); +} + +/// History older than the source's max age relative to the newest group is still served. +#[tokio::test(start_paused = true)] +async fn history_older_than_any_max_age_is_served() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.object("video", 0, vec![frames(true, 0, 0..2)]).await; + // Hours of media later. + archive.object("video", 0, vec![frames(true, 10_000, 0..2)]).await; + + let (broadcast, _run) = serve(&archive.store).await; + assert_eq!(fetch(&broadcast, "video", 0, 0).await.unwrap(), expected(true, 0, 0..2)); + let mut reader = subscribe(&broadcast, "video").await; + assert_eq!(next(&mut reader).await.unwrap(), (0, expected(true, 0, 0..2))); +} + +/// One GET serves every group of an object, and a group split across objects is served whole, +/// from any frame. +#[tokio::test(start_paused = true)] +async fn groups_come_from_cached_objects_and_span_them() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive + .object("video", 0, vec![frames(true, 0, 0..2), frames(true, 1, 0..2), frames(true, 2, 0..3)]) + .await; + archive.object("video", 3, vec![frames(true, 2, 3..5)]).await; + archive.object("video", 0, vec![frames(true, 3, 0..1)]).await; + + let (broadcast, _run) = serve(&archive.store).await; + let mock = archive.store.inner().clone(); + mock.take(); + assert_eq!(fetch(&broadcast, "video", 0, 0).await.unwrap(), expected(true, 0, 0..2)); + assert_eq!(fetch(&broadcast, "video", 1, 1).await.unwrap(), expected(true, 1, 1..2)); + assert_eq!(mock.gets().len(), 1, "both groups come from one object"); + + assert_eq!(fetch(&broadcast, "video", 2, 0).await.unwrap(), expected(true, 2, 0..5)); + // From a frame in the continuation only. + assert_eq!(fetch(&broadcast, "video", 2, 4).await.unwrap(), expected(true, 2, 4..5)); +} + +#[tokio::test(start_paused = true)] +async fn a_gap_reads_as_dropped() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive + .object("video", 0, vec![frames(true, 0, 0..1), frames(true, 2, 0..1)]) + .await; + archive.object("video", 0, vec![frames(true, 5, 0..1)]).await; + archive.object("video", 0, vec![frames(true, 6, 0..1)]).await; + + let (broadcast, _run) = serve(&archive.store).await; + // Inside an object, and between two. + not_found(fetch(&broadcast, "video", 1, 0).await); + not_found(fetch(&broadcast, "video", 3, 0).await); + not_found(fetch(&broadcast, "video", 4, 0).await); + + let mut reader = subscribe(&broadcast, "video").await; + for expected in [0, 2, 5, 6] { + assert_eq!(sequence(&mut reader).await, Some(expected)); + } +} + +/// A pruned start answers with what remains: the groups below it, and a group's pruned head, +/// read as dropped. +#[tokio::test(start_paused = true)] +async fn a_pruned_start_reads_as_dropped() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.object("video", 2, vec![frames(true, 5, 2..4), frames(true, 6, 0..2)]).await; + archive.object("video", 0, vec![frames(true, 7, 0..2)]).await; + archive.object("video", 0, vec![frames(true, 8, 0..1)]).await; + + let (broadcast, _run) = serve(&archive.store).await; + not_found(fetch(&broadcast, "video", 0, 0).await); + not_found(fetch(&broadcast, "video", 4, 0).await); + not_found(fetch(&broadcast, "video", 5, 0).await); + assert_eq!(fetch(&broadcast, "video", 5, 2).await.unwrap(), expected(true, 5, 2..4)); + assert_eq!(fetch(&broadcast, "video", 6, 0).await.unwrap(), expected(true, 6, 0..2)); + + // An object pruned after it was listed reads as dropped too. + archive.store.inner().hide_gets("rec/video/0000000000000000007-"); + not_found(fetch(&broadcast, "video", 7, 0).await); +} + +/// A growing archive is followed through each track's own cursor, a key sorting before +/// another track's cursor included, and a track added mid-recording joins. +#[tokio::test(start_paused = true)] +async fn a_growing_archive_is_followed() { + let archive = Archive::new(); + archive.track("a", 0, true).await; + archive.track("b", 0, true).await; + archive.object("a", 0, vec![frames(true, 0, 0..1)]).await; + archive.object("b", 0, vec![frames(true, 50, 0..1)]).await; + + let (broadcast, _run) = serve(&archive.store).await; + let mut a = subscribe(&broadcast, "a").await; + let mut b = subscribe(&broadcast, "b").await; + assert_eq!(sequence(&mut b).await, Some(50)); + + // `a/1-...` sorts before `b/50-...`: one prefix-wide cursor would skip it. + archive.object("a", 0, vec![frames(true, 1, 0..2)]).await; + archive.object("b", 0, vec![frames(true, 51, 0..1)]).await; + // A new object continuing the open group 51. + archive.object("b", 1, vec![frames(true, 51, 1..2), frames(true, 52, 0..1)]).await; + assert_eq!(next(&mut a).await.unwrap(), (0, expected(true, 0, 0..1))); + assert_eq!(sequence(&mut a).await, Some(1)); + assert_eq!(next(&mut b).await.unwrap(), (51, expected(true, 51, 0..2))); + + assert!(matches!( + broadcast.consume().track("c").unwrap().query().await, + Err(moq_net::Error::NotFound) + )); + archive.track("c", 0, false).await; + archive.object("c", 0, vec![frames(false, 0, 0..1)]).await; + sleep(2 * REFRESH).await; + let mut c = subscribe(&broadcast, "c").await; + archive.object("c", 0, vec![frames(false, 1, 0..1)]).await; + assert_eq!(next(&mut c).await.unwrap(), (0, expected(false, 0, 0..1))); +} + +/// Without markers, an archive whose producer stopped stays live. +#[tokio::test(start_paused = true)] +async fn an_archive_without_markers_stays_open() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.object("video", 0, vec![frames(true, 0, 0..1), frames(true, 1, 0..1)]).await; + // A track `end` alone is not final: the broadcast never wrote `.end`. + archive.end("video", End::Finish(At { group: 2, frame: 0 })).await; + + let (broadcast, _run) = serve(&archive.store).await; + let mut reader = subscribe(&broadcast, "video").await; + assert_eq!(next(&mut reader).await.unwrap(), (0, expected(true, 0, 0..1))); + let mut group = reader.next_group().await.unwrap().unwrap(); + assert!(group.read_frame().await.unwrap().is_some()); + let pending = timeout(30 * REFRESH, group.read_frame()).await; + assert!(pending.is_err(), "the newest group stays open"); +} + +/// A completed archive ends each track at its recorded end, mid-group included, and an +/// errored track replays its stored groups. +#[tokio::test(start_paused = true)] +async fn a_completed_archive_ends_as_recorded() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.track("audio", 0, true).await; + archive.track("errored", 0, true).await; + archive.object("video", 0, vec![frames(true, 0, 0..2), frames(true, 1, 0..2)]).await; + archive.object("audio", 0, vec![frames(true, 0, 0..2), frames(true, 1, 0..2)]).await; + archive.object("errored", 0, vec![frames(true, 0, 0..1), frames(true, 1, 0..1)]).await; + archive.end("video", End::Finish(At { group: 2, frame: 0 })).await; + archive.end("audio", End::Finish(At { group: 1, frame: 2 })).await; + archive.end("errored", End::Error(64 + 7)).await; + archive.complete().await; + + let (broadcast, _run) = serve(&archive.store).await; + for (track, end) in [("video", Position::group(2)), ("audio", Position { group: 1, frame: 2 })] { + let mut reader = subscribe(&broadcast, track).await; + assert_eq!(next(&mut reader).await.unwrap(), (0, expected(true, 0, 0..2))); + assert_eq!(next(&mut reader).await.unwrap(), (1, expected(true, 1, 0..2))); + assert!(next(&mut reader).await.is_none()); + assert_eq!(reader.finished().await.unwrap(), end, "{track}"); + } + + let mut reader = subscribe(&broadcast, "errored").await; + assert_eq!(next(&mut reader).await.unwrap().0, 0); + assert_eq!(next(&mut reader).await.unwrap().0, 1); + assert!(next(&mut reader).await.is_none()); +} + +/// Markers written while the consumer follows end the tracks then. +#[tokio::test(start_paused = true)] +async fn markers_end_a_followed_archive() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.object("video", 0, vec![frames(true, 0, 0..2)]).await; + + let (broadcast, _run) = serve(&archive.store).await; + let mut reader = subscribe(&broadcast, "video").await; + archive.object("video", 2, vec![frames(true, 0, 2..3)]).await; + archive.end("video", End::Finish(At { group: 1, frame: 0 })).await; + archive.complete().await; + assert_eq!(next(&mut reader).await.unwrap(), (0, expected(true, 0, 0..3))); + assert!(next(&mut reader).await.is_none()); +} + +/// Groups and datagrams sharing one sequence space replay in their recorded forms. +#[tokio::test(start_paused = true)] +async fn groups_and_datagrams_keep_their_forms() { + let archive = Archive::new(); + archive.track("mixed", 0, false).await; + archive + .object("mixed", 0, vec![frames(false, 0, 0..1), datagram(false, 1), frames(false, 2, 0..1)]) + .await; + + let (broadcast, _run) = serve(&archive.store).await; + let track = broadcast.consume().track("mixed").unwrap(); + let subscription = Subscription::default() + .with_groups(0..) + .with_order(Order::Ascending) + .with_max_delay(None); + let mut reader = track.subscribe(subscription).await.unwrap(); + + archive.object("mixed", 0, vec![datagram(false, 3), frames(false, 4, 0..1)]).await; + let datagram = timeout(10 * REFRESH, reader.recv_datagram()) + .await + .expect("the datagram arrives") + .unwrap() + .unwrap(); + assert_eq!((datagram.sequence, &datagram.payload[..]), (3, &b"3/0"[..])); + + // A stored datagram is never fetched as a group. + assert!(fetch(&broadcast, "mixed", 1, 0).await.is_err()); + assert_eq!(fetch(&broadcast, "mixed", 0, 0).await.unwrap(), ["0/0"]); + assert_eq!(fetch(&broadcast, "mixed", 2, 0).await.unwrap(), ["2/0"]); + + // The newest stored group arrives live as a group, still open. + let mut sequences = Vec::new(); + while sequences.last() != Some(&4) { + let group = timeout(10 * REFRESH, reader.recv_group()).await.unwrap().unwrap().unwrap(); + sequences.push(group.sequence); + } + assert!(!sequences.contains(&1) && !sequences.contains(&3), "{sequences:?}"); +} + +/// A track removed and re-added under its name replays both runs in sequence. +#[tokio::test(start_paused = true)] +async fn a_re_added_track_replays_both_runs() { + let store = Store::new(Mock::memory(), "rec"); + let source = broadcast::Info::new().produce(); + let info = track::Info::default().with_timescale(Timescale::MILLI); + let config = crate::producer::Config::default().with_flush(Duration::from_secs(1)); + let recording = tokio::spawn(crate::Producer::new(store.clone(), source.consume(), config).run()); + + for (run, sequences) in [(0, 0..2), (1, 2..4)] { + let track = source.create_track("video", info.clone()).unwrap(); + while track.subscription().is_none() { + sleep(Duration::from_millis(1)).await; + } + for sequence in sequences { + let mut group = track.create_group(group::Info { sequence }).unwrap(); + let pts = Timestamp::from_millis(sequence * 1000).unwrap(); + group.write_frame(pts, format!("run{run}/{sequence}")).unwrap(); + group.finish().unwrap(); + } + track.finish().unwrap(); + sleep(Duration::from_secs(2)).await; + drop(track); + sleep(Duration::from_millis(10)).await; + } + source.close(); + recording.await.unwrap().unwrap(); + + let (broadcast, _run) = serve(&store).await; + let mut reader = subscribe(&broadcast, "video").await; + let mut read = Vec::new(); + while let Some((_, frames)) = next(&mut reader).await { + read.extend(frames); + } + assert_eq!(read, ["0@run0/0", "1000@run0/1", "2000@run1/2", "3000@run1/3"]); +} + +#[tokio::test(start_paused = true)] +async fn an_empty_or_unrecognized_prefix_is_refused() { + let open = |store: Store| async move { + let broadcast = broadcast::Info::new().produce(); + Consumer::open(store, broadcast, Config::default()).await.err() + }; + + let archive = Archive::new(); + assert!(matches!(open(archive.store.clone()).await, Some(Error::NotFound(_)))); + + // A key the layout does not name, at the root or under a track. + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.raw("stray", b"x").await; + assert!(matches!(open(archive.store.clone()).await, Some(Error::Path(_)))); + + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.raw("video/segments/0000000000000000000", b"x").await; + assert!(matches!(open(archive.store.clone()).await, Some(Error::Path(_)))); + + // A directory that is not a canonical track name. + let archive = Archive::new(); + archive.raw("catalog.json/.info", br#"{"version":2,"priority":0}"#).await; + assert!(matches!(open(archive.store.clone()).await, Some(Error::Track))); +} + +/// Object stores promise no listing order. +#[tokio::test(start_paused = true)] +async fn an_unordered_listing_is_sorted() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + for sequence in 0..4 { + archive.object("video", 0, vec![frames(true, sequence, 0..1)]).await; + } + archive.store.inner().unordered(); + + let (broadcast, _run) = serve(&archive.store).await; + let mut reader = subscribe(&broadcast, "video").await; + for sequence in 0..3 { + assert_eq!(next(&mut reader).await.unwrap(), (sequence, expected(true, sequence, 0..1))); + } + assert_eq!(super::tests::sequence(&mut reader).await, Some(3)); +} diff --git a/rs/moq-archive/src/consumer/track.rs b/rs/moq-archive/src/consumer/track.rs new file mode 100644 index 00000000000..07273b51ad2 --- /dev/null +++ b/rs/moq-archive/src/consumer/track.rs @@ -0,0 +1,278 @@ +//! One served track: its live feed of what the archive gains, and fills of what it holds. + +use std::sync::{Arc, Mutex}; + +use moq_net::track::{Fill, Position}; +use moq_net::{Timescale, Timestamp, group, track}; +use object_store::ObjectStore; + +use super::Shared; +use super::index::Index; +use crate::layout::{End, Entry, Frame, Object}; +use crate::{Error, Info, Result}; + +/// A track's live feed: its newest stored sequence when found, then everything stored after. +pub(super) struct Live { + producer: track::Producer, + timescale: Option, + /// The newest group, kept open since a later object may continue it. + open: Option, + /// Whether the feed declared where it starts; fills serve everything before. + started: bool, +} + +struct Open { + group: group::Producer, + sequence: u64, + /// The index of the next frame it takes. + next: u64, +} + +impl Live { + pub fn new(producer: track::Producer) -> Self { + let timescale = producer.info().timescale; + Self { + producer, + timescale, + open: None, + started: false, + } + } + + pub fn is_started(&self) -> bool { + self.started + } + + /// Feed `objects`' entries from sequence `from` on, in order. + pub fn push(&mut self, objects: &[Arc], from: u64) -> Result<()> { + for object in objects { + for (index, entry) in object.entries.iter().enumerate() { + if entry.sequence() < from { + continue; + } + if !self.started { + // Readers ask fills for everything older. + self.producer.start_at(entry.sequence())?; + self.started = true; + } + let first = match index { + 0 => object.frame_start, + _ => 0, + }; + self.entry(entry, first)?; + } + } + Ok(()) + } + + fn entry(&mut self, entry: &Entry, first: u64) -> Result<()> { + match entry { + Entry::Datagram { sequence, frame } => { + // A later sequence completes the open group. + self.close()?; + let timestamp = timestamp(frame.timestamp, self.timescale)?; + self.producer + .insert_datagram(*sequence, timestamp, frame.payload.clone())?; + } + Entry::Group { sequence, frames } => { + let mut open = match self.open.take() { + Some(open) if open.sequence == *sequence => { + if open.next != first { + return Err(Error::Sequence); + } + open + } + other => { + if let Some(mut other) = other { + other.group.finish()?; + } + let mut group = self.producer.create_group(group::Info { sequence: *sequence })?; + if first > 0 { + // Its head was pruned. + group.start_at(first)?; + } + Open { + group, + sequence: *sequence, + next: first, + } + } + }; + for frame in frames { + let timestamp = timestamp(frame.timestamp, self.timescale)?; + open.group.write_frame(timestamp, frame.payload.clone())?; + open.next += 1; + } + self.open = Some(open); + } + } + Ok(()) + } + + fn close(&mut self) -> Result<()> { + if let Some(mut open) = self.open.take() { + open.group.finish()?; + } + Ok(()) + } + + /// End the feed as the archive recorded. + pub fn end(&mut self, end: End, track: &str) -> Result<()> { + self.close()?; + match end { + End::Finish(at) => self.producer.finish_at(Position { + group: at.group, + frame: at.frame, + })?, + End::Error(code) => { + // An abort would refuse every later reader the stored history, so the track + // ends cleanly after its newest stored sequence instead. + tracing::warn!(track, code, "archived track ended with an error"); + self.producer.finish()?; + } + } + Ok(()) + } +} + +/// Serve `fill` from the archive, a group at a time in the fill's order, until every group is +/// answered or nobody waits on it. +pub(super) async fn serve(shared: Arc>, index: Arc>, fill: Fill) { + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + serve_group(&shared, &index, &fill, sequence).await; + } +} + +/// Answer group `sequence` of `fill` from the objects holding it, or drop the run of sequences +/// around it that the archive does not hold. +async fn serve_group(shared: &Shared, index: &Mutex, fill: &Fill, sequence: u64) { + let frame = match fill.start().group == sequence { + true => fill.start().frame, + false => 0, + }; + let (holding, after, timed, timescale) = { + let index = index.lock().unwrap(); + let (holding, after) = index.holding(sequence, frame); + (holding, after, index.timed(), index.timescale) + }; + let next = after.map_or(u64::MAX, |start| start.0); + + let mut objects = Vec::with_capacity(holding.len()); + for (start, path) in &holding { + match super::object(shared, path, *start, timed).await { + Ok(object) => objects.push(object), + Err(err) => { + if let Error::NotFound(_) = err { + // Pruned since it was listed. + index.lock().unwrap().forget(*start); + } else { + tracing::warn!(%path, sequence, %err, "archived group is unavailable"); + } + fill.drop_groups(sequence..sequence + 1); + return; + } + } + } + + let Some(first) = objects.first() else { + // Older than the oldest stored object: pruned, or never stored. + fill.drop_groups(0..next); + return; + }; + let Some(position) = first.entries.iter().position(|entry| entry.sequence() >= sequence) else { + // A gap after the object's last entry. + let last = first.entries.last().map_or(0, |entry| entry.sequence() + 1); + fill.drop_groups(last..next); + return; + }; + let frames = match &first.entries[position] { + Entry::Group { sequence: found, frames } if *found == sequence => frames, + // A datagram stays lossy and uncached, never a fetched group. + Entry::Datagram { sequence: found, .. } if *found == sequence => { + fill.drop_groups(sequence..sequence + 1); + return; + } + // A gap between two entries. + other => { + let gap = match position { + 0 => sequence, + _ => first.entries[position - 1].sequence() + 1, + }; + fill.drop_groups(gap..other.sequence()); + return; + } + }; + let start = match position { + 0 => first.frame_start, + _ => 0, + }; + if start > frame { + // Its head was pruned. + fill.drop_groups(sequence..sequence + 1); + return; + } + + // The group's frames, with their indices, across every object holding it. + let mut stored: Vec<(u64, &Frame)> = (start..).zip(frames).collect(); + for object in &objects[1..] { + let next = start + stored.len() as u64; + match object.entries.first() { + Some(Entry::Group { sequence: found, frames }) if *found == sequence && object.frame_start == next => { + stored.extend((next..).zip(frames)); + } + _ => { + // A continuation went missing, so the group can't be served whole. + fill.drop_groups(sequence..sequence + 1); + return; + } + } + } + + let Ok(mut producer) = fill.accept(sequence, None) else { + return; + }; + for (index, stored) in stored { + if index < frame { + continue; + } + let written = timestamp(stored.timestamp, timescale) + .map_err(|_| moq_net::Error::ProtocolViolation) + .and_then(|timestamp| producer.write_frame(timestamp, stored.payload.clone())); + if let Err(err) = written { + tracing::warn!(sequence, %err, "archived group is malformed"); + let _ = producer.abort(err); + return; + } + } + let _ = producer.finish(); +} + +/// The served properties of a track stored with `info`: no max age, since the archive's +/// retention bounds what it holds and the source's would expire archived history. +pub(super) fn info(info: &Info) -> Result { + Ok(track::Info::default() + .with_priority(info.priority) + .with_max_age(None) + .with_timescale(timescale(info)?)) +} + +/// A stored track's timescale, `None` when untimed. +pub(super) fn timescale(info: &Info) -> Result> { + info.timescale + .map(|timescale| Timescale::new(timescale).map_err(|_| Error::Timescale(timescale))) + .transpose() +} + +/// A stored timestamp on its track, which must be timed exactly when the frame is. +fn timestamp(value: Option, timescale: Option) -> Result> { + match (value, timescale) { + (Some(value), Some(timescale)) => Ok(Some(Timestamp::new(value, timescale).map_err(|_| Error::Id(value))?)), + (None, None) => Ok(None), + _ => Err(Error::Timing), + } +} diff --git a/rs/moq-archive/src/error.rs b/rs/moq-archive/src/error.rs index 6bbd956dd41..93aa592402c 100644 --- a/rs/moq-archive/src/error.rs +++ b/rs/moq-archive/src/error.rs @@ -14,7 +14,7 @@ pub enum Error { #[error("unknown version {0}")] Version(u64), - /// A group ID, segment ID, or frame timestamp is outside 0 through 2^53 - 1. + /// A sequence, frame index, or timestamp is outside 0 through 2^53 - 1. #[error("{0} exceeds the recording id range")] Id(u64), @@ -48,10 +48,6 @@ pub enum Error { #[error("group sequences are not strictly ascending")] Sequence, - /// An object's table does not hold exactly the frames its record names. - #[error("object does not match its record")] - Span, - /// The binary table is truncated, overlapping, gapped, or out of range. #[error("malformed table")] Table, @@ -68,14 +64,6 @@ pub enum Error { #[error("invalid path: {0}")] Path(String), - /// A backend returned a directory from the flat object listing API. - #[error("directory listing is unsupported: {0}")] - Directory(String), - - /// A pagination continuation was passed to streaming list. - #[error("continuation query requires list_paginated")] - Pagination, - /// A create collided with different object bytes. #[error("conflicting object: {0}")] Conflict(String), @@ -84,7 +72,7 @@ pub enum Error { #[error("json: {0}")] Json(String), - /// Publishing the replayed archive failed. + /// Serving the archive on its broadcast failed. #[error("moq: {0}")] Moq(String), diff --git a/rs/moq-archive/src/info.rs b/rs/moq-archive/src/info.rs index ff90254914d..5c01ae984b4 100644 --- a/rs/moq-archive/src/info.rs +++ b/rs/moq-archive/src/info.rs @@ -6,7 +6,7 @@ use crate::{Error, Result, VERSION}; /// Immutable track properties stored at `/.info`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(from = "Stored")] +#[serde(deny_unknown_fields)] pub struct Info { /// Recording format version; must be [`VERSION`]. pub version: u64, @@ -18,29 +18,6 @@ pub struct Info { pub timescale: Option, } -/// [`Info`] as stored, also taking the `epoch` an older `Writer` recorded, so [`Reader`](crate::Reader) -/// still replays those recordings; it goes when `Reader` does. -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Stored { - version: u64, - priority: u8, - #[serde(default)] - timescale: Option, - #[serde(default, rename = "epoch")] - _epoch: Option, -} - -impl From for Info { - fn from(stored: Stored) -> Self { - Self { - version: stored.version, - priority: stored.priority, - timescale: stored.timescale, - } - } -} - impl Info { /// The current format version with `priority` and `timescale`, `None` for an untimed track. pub fn new(priority: u8, timescale: Option) -> Result { @@ -128,18 +105,12 @@ mod tests { assert!(matches!(Info::new(0, Some(ID_MAX + 1)), Err(Error::Timescale(_)))); } - #[test] - fn an_older_writers_epoch_is_ignored() { - let info = Info::decode(br#"{"version":2,"priority":0,"timescale":1000,"epoch":"x"}"#).unwrap(); - assert_eq!(info, Info::new(0, Some(1000)).unwrap()); - assert_eq!( - info.encode().unwrap().as_ref(), - br#"{"version":2,"priority":0,"timescale":1000}"# - ); - } - #[test] fn unknown_members_and_missing_fields_are_refused() { + assert!(matches!( + Info::decode(br#"{"version":2,"priority":0,"timescale":1000,"epoch":"x"}"#), + Err(Error::Json(_)) + )); assert!(matches!( Info::decode(br#"{"version":2,"priority":0,"timescale":1,"extra":true}"#), Err(Error::Json(_)) diff --git a/rs/moq-archive/src/layout.rs b/rs/moq-archive/src/layout.rs index a2c44366129..dba0302bcbd 100644 --- a/rs/moq-archive/src/layout.rs +++ b/rs/moq-archive/src/layout.rs @@ -9,7 +9,6 @@ use object_store::path::Path; use serde::{Deserialize, Serialize}; use crate::path::{check_id, decode_track, encode_track, push}; -use crate::segment::Frame; use crate::{Error, ID_WIDTH, Result, VERSION}; /// The broadcast's completion marker, beside its track segments. @@ -144,6 +143,15 @@ impl End { } } +/// One frame's timestamp and original payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Frame { + /// Absolute timestamp in the track's timescale, or `None` on an untimed track. + pub timestamp: Option, + /// Original frame payload, including any group-scoped compression. + pub payload: Bytes, +} + /// One stored sequence, in the form it was forwarded in. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Entry { @@ -190,6 +198,15 @@ impl Object { }) } + /// Its payload bytes plus a small per-frame overhead, for a byte-bounded cache. + pub(crate) fn weight(&self) -> u64 { + let frames = self.entries.iter().flat_map(|entry| match entry { + Entry::Group { frames, .. } => frames.as_slice(), + Entry::Datagram { frame, .. } => std::slice::from_ref(frame), + }); + frames.map(|frame| frame.payload.len() as u64 + 32).sum::() + 64 + } + /// The position just past the last stored frame or datagram. pub(crate) fn end(&self) -> Result { let last = self.entries.last().ok_or(Error::Empty)?; diff --git a/rs/moq-archive/src/lib.rs b/rs/moq-archive/src/lib.rs index 2871111019b..a9397f83429 100644 --- a/rs/moq-archive/src/lib.rs +++ b/rs/moq-archive/src/lib.rs @@ -1,7 +1,7 @@ //! Broadcast archives on any [`object_store::ObjectStore`]. //! //! A [`Producer`] stores every track of one broadcast under a key prefix the host picks, and a -//! [`Reader`] serves a recording back through a `moq_net` broadcast. Callers that need runtime +//! [`Consumer`] serves that prefix back as an ordinary `moq_net` broadcast. Callers that need runtime //! dispatch supply `Arc`; the archive API itself stays generic. //! //! # Layout @@ -59,6 +59,7 @@ pub use object_store; +pub mod consumer; mod error; /// The `.info` object holding a track's immutable properties. pub mod info; @@ -67,25 +68,20 @@ mod layout; mod mock; mod path; pub mod producer; -pub mod reader; -/// The binary segment object holding a record's groups and frames. -pub mod segment; -/// Put, get, list, and delete recording objects on an object store. +/// The object store and key prefix an archive lives under. pub mod store; +pub use consumer::Consumer; pub use error::{Error, Result}; pub use info::Info; -pub use path::Key; pub use producer::Producer; -pub use reader::Reader; -pub use segment::{Frame, Group, Object}; pub use store::Store; /// Recording format version written into `.info` and the binary envelope. pub const VERSION: u64 = 2; -/// Largest group, segment, or timestamp value the format allows (`2^53 - 1`). +/// Largest sequence, frame index, or timestamp value the format allows (`2^53 - 1`). pub const ID_MAX: u64 = (1 << 53) - 1; -/// Decimal width of segment ID filename fields. +/// Decimal width of each object key field. pub(crate) const ID_WIDTH: usize = 19; diff --git a/rs/moq-archive/src/mock.rs b/rs/moq-archive/src/mock.rs index 76d1f43adc7..a5e4fc9018b 100644 --- a/rs/moq-archive/src/mock.rs +++ b/rs/moq-archive/src/mock.rs @@ -36,8 +36,6 @@ struct State { fail_puts: Vec, /// GETs whose path contains any of these return Not Found. hide_gets: Vec, - /// GETs whose path contains the pattern wait for a permit before reading. - stall_gets: Vec<(String, Arc)>, /// Deletes whose path contains any of these fail. fail_deletes: Vec, /// Streaming listings end with an error after every entry. @@ -104,13 +102,6 @@ impl Mock { self } - /// Hold every GET whose path contains `pattern` until the returned semaphore gets a permit. - pub fn stall_gets(&self, pattern: &str) -> Arc { - let gate = Arc::new(tokio::sync::Semaphore::new(0)); - self.state().stall_gets.push((pattern.to_string(), gate.clone())); - gate - } - pub fn fail_deletes(&self, pattern: &str) -> &Self { self.state().fail_deletes.push(pattern.to_string()); self @@ -138,19 +129,6 @@ impl Mock { self.hold.notify_waiters(); } - /// Clear every injected failure. - pub fn heal(&self) { - let mut state = self.state(); - state.fail_puts.clear(); - state.hide_gets.clear(); - state.stall_gets.clear(); - state.fail_deletes.clear(); - state.fail_lists = false; - state.holds.clear(); - drop(state); - self.hold.notify_waiters(); - } - /// Wait out a [`hold_puts`](Self::hold_puts) gate. /// /// The waiter is armed before the check, so a `release` in between is not missed. @@ -255,15 +233,6 @@ impl ObjectStore for Mock { source: "hidden".into(), }); } - let stall = self - .state() - .stall_gets - .iter() - .find(|(pattern, _)| location.as_ref().contains(pattern)) - .map(|(_, gate)| gate.clone()); - if let Some(gate) = stall { - gate.acquire().await.expect("stall gate closed").forget(); - } self.inner.get_opts(location, options).await } diff --git a/rs/moq-archive/src/path.rs b/rs/moq-archive/src/path.rs index 576067c3ff3..2f5488fd869 100644 --- a/rs/moq-archive/src/path.rs +++ b/rs/moq-archive/src/path.rs @@ -1,7 +1,7 @@ use object_store::path::{Path, PathPart}; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; -use crate::{Error, ID_MAX, ID_WIDTH, Result}; +use crate::{Error, ID_MAX, Result}; /// Bytes that are not `A-Z a-z 0-9 _ -` and must be percent-encoded in a track name. const TRACK: &AsciiSet = &NON_ALPHANUMERIC.remove(b'_').remove(b'-'); @@ -26,131 +26,11 @@ pub(crate) fn decode_track(encoded: &str) -> Result { Ok(decoded.into_owned()) } -/// Write a segment ID as 19 zero-padded decimal digits. -pub(crate) fn format_id(id: u64) -> Result { - check_id(id)?; - Ok(format!("{id:0width$}", width = ID_WIDTH)) -} - -/// Parse a 19-digit decimal ID field, refusing values outside the recording range. -pub(crate) fn parse_id(field: &str) -> Result { - if field.len() != ID_WIDTH || !field.bytes().all(|b| b.is_ascii_digit()) { - return Err(Error::Path(field.to_string())); - } - let id: u64 = field.parse().map_err(|_| Error::Path(field.to_string()))?; - check_id(id) -} - /// Refuse an ID outside 0 through 2^53 - 1. pub(crate) fn check_id(id: u64) -> Result { if id > ID_MAX { Err(Error::Id(id)) } else { Ok(id) } } -/// A recording object key under an application prefix. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum Key { - /// `/.info` - Info { - /// The unencoded track name. - track: String, - }, - /// `/segments/`: record `segment` of a track's timeline, or for a - /// timeline track, the window groups closed while committing that record. - Segments { - /// The unencoded track name. - track: String, - /// The record's sequence. - segment: u64, - }, -} - -impl Key { - /// A track's `.info` object. - pub fn info(track: impl Into) -> Result { - let track = track.into(); - encode_track(&track)?; - Ok(Self::Info { track }) - } - - /// The object at `segments/`. - pub fn segments(track: impl Into, segment: u64) -> Result { - let track = track.into(); - encode_track(&track)?; - check_id(segment)?; - Ok(Self::Segments { track, segment }) - } - - /// The unencoded track name this key belongs to. - pub fn track(&self) -> &str { - match self { - Self::Info { track } | Self::Segments { track, .. } => track, - } - } - - /// Encode this key under `prefix`. - pub(crate) fn path(&self, prefix: &Path) -> Result { - let path = push(prefix, &encode_track(self.track())?)?; - match self { - Self::Info { .. } => push(&path, ".info"), - Self::Segments { segment, .. } => { - let path = push(&path, "segments")?; - push(&path, &format_id(*segment)?) - } - } - } - - /// Parse a store location relative to `prefix`. - pub(crate) fn parse(prefix: &Path, location: &Path) -> Result { - if prefix.as_ref().is_empty() { - return parse_parts(location.parts(), location); - } - let parts = location - .prefix_match(prefix) - .ok_or_else(|| Error::Path(location.to_string()))?; - parse_parts(parts, location) - } -} - -fn parse_parts<'a>(mut parts: impl Iterator>, location: &Path) -> Result { - let encoded = parts.next().ok_or_else(|| Error::Path(location.to_string()))?; - let track = decode_track(encoded.as_ref())?; - let kind = parts.next().ok_or_else(|| Error::Path(location.to_string()))?; - match kind.as_ref() { - ".info" => { - if parts.next().is_some() { - return Err(Error::Path(location.to_string())); - } - Ok(Key::Info { track }) - } - "segments" => { - let name = parts.next().ok_or_else(|| Error::Path(location.to_string()))?; - if parts.next().is_some() { - return Err(Error::Path(location.to_string())); - } - Ok(Key::Segments { - track, - segment: parse_id(name.as_ref())?, - }) - } - _ => Err(Error::Path(location.to_string())), - } -} - -pub(crate) fn track_prefix(track: &str) -> Result { - push(&Path::ROOT, &encode_track(track)?) -} - -pub(crate) fn segments_prefix(prefix: &Path, track: &str) -> Result { - let path = append(prefix, &track_prefix(track)?); - push(&path, "segments") -} - -fn append(base: &Path, relative: &Path) -> Path { - let mut path = base.clone(); - path.extend(relative.parts()); - path -} - pub(crate) fn push(base: &Path, segment: &str) -> Result { let part = PathPart::parse(segment).map_err(|err| Error::Path(err.to_string()))?; Ok(base.clone().join(part)) @@ -196,92 +76,4 @@ mod tests { assert!(matches!(decode_track("a/b"), Err(Error::Track))); assert!(matches!(decode_track(".info"), Err(Error::Track))); } - - #[test] - fn id_fields_are_19_digits() { - assert_eq!(format_id(0).unwrap(), "0000000000000000000"); - assert_eq!(format_id(ID_MAX).unwrap(), "0009007199254740991"); - assert_eq!(parse_id("0000000000000000000").unwrap(), 0); - assert_eq!(parse_id("0009007199254740991").unwrap(), ID_MAX); - } - - #[test] - fn ids_outside_the_json_range_are_rejected() { - assert!(matches!(format_id(ID_MAX + 1), Err(Error::Id(_)))); - assert!(matches!(check_id(u64::MAX), Err(Error::Id(_)))); - assert!(matches!( - parse_id("0009007199254740992"), - Err(Error::Id(n)) if n == ID_MAX + 1 - )); - // The largest QUIC varint still fits the 19-digit field. - let varint = (1u64 << 62) - 1; - assert!(matches!(format_id(varint), Err(Error::Id(n)) if n == varint)); - assert!(matches!(parse_id("4611686018427387903"), Err(Error::Id(n)) if n == varint)); - assert!(matches!(parse_id("5"), Err(Error::Path(_)))); - assert!(matches!(parse_id("000000000000000000X"), Err(Error::Path(_)))); - } - - #[test] - fn keys_roundtrip_under_a_prefix() { - let prefix = Path::from("rec/1"); - for key in [ - Key::info("catalog.json").unwrap(), - Key::segments("video", 5).unwrap(), - Key::segments("timeline.z", 0).unwrap(), - Key::segments("timeline.z", ID_MAX).unwrap(), - ] { - let path = key.path(&prefix).unwrap(); - assert_eq!(Key::parse(&prefix, &path).unwrap(), key); - } - } - - #[test] - fn encoded_track_is_not_double_encoded() { - let path = Key::info("catalog.json").unwrap().path(&Path::from("rec")).unwrap(); - assert_eq!(path.as_ref(), "rec/catalog%2Ejson/.info"); - } - - #[test] - fn direct_construction_is_validated_when_serialized() { - let prefix = Path::from("rec"); - let empty = Key::Info { track: String::new() }; - assert!(matches!(empty.path(&prefix), Err(Error::Track))); - let segment = Key::Segments { - track: "t".to_string(), - segment: ID_MAX + 1, - }; - assert!(matches!(segment.path(&prefix), Err(Error::Id(_)))); - - // Everything that does serialize parses back to the same key. - for key in [ - Key::Info { - track: "a/b".to_string(), - }, - Key::Segments { - track: ".x".to_string(), - segment: ID_MAX, - }, - Key::Segments { - track: "é".to_string(), - segment: 0, - }, - ] { - let path = key.path(&prefix).unwrap(); - assert_eq!(Key::parse(&prefix, &path).unwrap(), key); - } - } - - #[test] - fn keys_parse_under_an_empty_prefix() { - let key = Key::segments("catalog.json", 2).unwrap(); - let path = key.path(&Path::ROOT).unwrap(); - assert_eq!(path.as_ref(), "catalog%2Ejson/segments/0000000000000000002"); - assert_eq!(Key::parse(&Path::ROOT, &path).unwrap(), key); - } - - #[test] - fn a_retired_directory_is_not_a_key() { - let path = Path::from("video/groups/0000000000000000001.0000000000000000001"); - assert!(matches!(Key::parse(&Path::ROOT, &path), Err(Error::Path(_)))); - } } diff --git a/rs/moq-archive/src/producer/track.rs b/rs/moq-archive/src/producer/track.rs index 7059a8619c7..e0fbff968b4 100644 --- a/rs/moq-archive/src/producer/track.rs +++ b/rs/moq-archive/src/producer/track.rs @@ -16,8 +16,7 @@ use object_store::{ObjectStore, ObjectStoreExt, PutMode, PutPayload}; use super::Shared; use super::ordered::{Item, Ordered}; use super::resume::Stored; -use crate::layout::{self, End, Entry, Object}; -use crate::segment::Frame; +use crate::layout::{self, End, Entry, Frame, Object}; use crate::{Error, Info, Result}; /// Record each run of the track `name` reported through `queue`, until the queue closes with the diff --git a/rs/moq-archive/src/reader/index.rs b/rs/moq-archive/src/reader/index.rs deleted file mode 100644 index 4cafc63d2c9..00000000000 --- a/rs/moq-archive/src/reader/index.rs +++ /dev/null @@ -1,170 +0,0 @@ -use std::collections::{BTreeMap, HashMap}; -use std::ops::Range; - -use hang::timeline::{Position, Record}; - -use crate::path::check_id; - -/// One committed record: the object at `segments/` and the frames it holds. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct Span { - /// The record's sequence, which names its object. - pub sequence: u64, - /// The first frame, inclusive. - pub start: Position, - /// The end, exclusive. - pub end: Position, -} - -impl Span { - /// Build from a record, refusing an empty span or IDs past the recording range. - fn new(record: &Record) -> Option { - check_id(record.sequence).ok()?; - check_id(record.start.group).ok()?; - check_id(record.end.group).ok()?; - (record.start < record.end).then_some(Self { - sequence: record.sequence, - start: record.start, - end: record.end, - }) - } -} - -/// One track's committed spans. -#[derive(Default)] -struct Spans { - /// Each span keyed by its start. - spans: BTreeMap, - /// Each span's start keyed by its sequence, so a pop can find it. - sequences: BTreeMap, -} - -/// The spans each track's replayed timeline window commits. -#[derive(Default)] -pub(super) struct Index { - tracks: HashMap, -} - -impl Index { - /// Add `track`'s record. - /// - /// A malformed record or one overlapping another is left out, so its frames behave like ones - /// the source never delivered; the track's other records stay usable. - pub fn push(&mut self, track: &str, record: &Record) { - let Some(span) = Span::new(record) else { - tracing::warn!(track, sequence = record.sequence, "ignoring a malformed archive record"); - return; - }; - - let spans = self.tracks.entry(track.to_string()).or_default(); - let before = spans.spans.range(..span.end).next_back(); - if before.is_some_and(|(_, prev)| prev.end > span.start) || spans.sequences.contains_key(&span.sequence) { - tracing::warn!( - track, - sequence = record.sequence, - "ignoring an overlapping archive record" - ); - return; - } - spans.spans.insert(span.start, span); - spans.sequences.insert(span.sequence, span.start); - } - - /// Remove `track`'s records with these sequences, returning the evicted spans. - pub fn pop(&mut self, track: &str, sequences: Range) -> Vec { - let Some(spans) = self.tracks.get_mut(track) else { - return Vec::new(); - }; - let popped: Vec = spans - .sequences - .range(sequences) - .map(|(sequence, _)| *sequence) - .collect(); - popped - .into_iter() - .filter_map(|sequence| { - let start = spans.sequences.remove(&sequence)?; - spans.spans.remove(&start) - }) - .collect() - } - - /// The spans holding frames of `group` on `track`, in order. - pub fn group(&self, track: &str, group: u64) -> Vec { - let Some(spans) = self.tracks.get(track) else { - return Vec::new(); - }; - let head = Position::group(group); - let mut found: Vec = spans - .spans - .range(..Position::group(group.saturating_add(1))) - .rev() - .map(|(_, span)| *span) - .take_while(|span| span.end > head) - .collect(); - found.reverse(); - found - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ID_MAX; - - fn record(sequence: u64, start: Position, end: Position) -> Record { - Record::new(sequence, sequence * 1000, 1000, start, end) - } - - fn at(group: u64) -> Position { - Position::group(group) - } - - #[test] - fn lookup_finds_every_span_of_a_group() { - let mut index = Index::default(); - index.push("audio", &record(0, at(0), at(3))); - index.push("audio", &record(1, at(5), at(6))); - // Group 7 split across two records, then whole groups again. - index.push("video", &record(0, at(7), Position::new(7, 3))); - index.push("video", &record(1, Position::new(7, 3), at(9))); - - let sequences = |spans: Vec| spans.iter().map(|span| span.sequence).collect::>(); - assert_eq!(sequences(index.group("audio", 1)), vec![0]); - assert!(index.group("audio", 3).is_empty(), "the gap between records"); - assert_eq!(sequences(index.group("audio", 5)), vec![1]); - assert!(index.group("audio", 6).is_empty()); - assert_eq!(sequences(index.group("video", 7)), vec![0, 1]); - assert_eq!(sequences(index.group("video", 8)), vec![1]); - assert!(index.group("chat", 0).is_empty()); - } - - #[test] - fn pop_evicts_only_the_popped_records() { - let mut index = Index::default(); - index.push("video", &record(0, at(0), at(2))); - index.push("video", &record(1, at(2), at(4))); - - let evicted = index.pop("video", 0..1); - assert_eq!(evicted.len(), 1); - assert_eq!(evicted[0].start, at(0)); - assert!(index.group("video", 0).is_empty()); - assert_eq!(index.group("video", 2).len(), 1); - assert!(index.pop("video", 0..1).is_empty(), "already popped"); - } - - #[test] - fn malformed_and_overlapping_records_are_ignored() { - let mut index = Index::default(); - index.push("video", &record(0, at(4), at(7))); - index.push("video", &record(1, at(6), at(9))); - assert_eq!(index.group("video", 8), Vec::new(), "overlapping the earlier span"); - index.push("video", &record(2, at(3), at(5))); - assert_eq!(index.group("video", 3), Vec::new(), "overlapping the later span"); - - index.push("audio", &record(0, at(3), at(3))); - assert!(index.group("audio", 3).is_empty(), "an empty span"); - index.push("data", &record(0, at(ID_MAX + 1), at(ID_MAX + 2))); - assert!(index.group("data", ID_MAX + 1).is_empty(), "past the recording range"); - } -} diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs deleted file mode 100644 index b09a16c4f73..00000000000 --- a/rs/moq-archive/src/reader/mod.rs +++ /dev/null @@ -1,684 +0,0 @@ -//! Serve an archive's groups through a caller-supplied broadcast. -//! -//! A [`Reader`] replays each track's timeline onto its timeline track and answers FETCH for every -//! frame those timelines commit. A group maps to the records holding it, each one object fetched -//! with a single GET, validated against its record, and kept in a byte-bounded cache so adjacent -//! groups reuse it. A request for one track never downloads another track's object. A group whose -//! records do not reach its end yet, such as an append-only log, grows as later records commit. -//! Timeline groups remain FETCHable from their original objects after broadcast-cache eviction. -//! -//! The reader never infers that a recording ended: it keeps following until the caller, who may -//! know finality out of band, calls [`Reader::finish`]. -//! -//! A track whose `.info` declares no timescale replays untimed, along with its timeline: its frames -//! carry no timestamps, and nothing stands in for one. -//! -//! ```no_run -//! # async fn example(store: moq_archive::Store) -> moq_archive::Result<()> { -//! use moq_archive::reader::{Config, Reader}; -//! -//! let broadcast = moq_net::broadcast::Info::new().produce(); -//! let timelines = [("video".to_string(), "video.timeline.z".to_string())].into(); -//! let mut reader = Reader::open(store, &broadcast, Config::new(timelines)).await?; -//! let serve = reader.serve(); -//! // Spawn `serve` on any executor, then call `reader.refresh()` to follow a growing archive. -//! # drop(serve); -//! # Ok(()) -//! # } -//! ``` - -mod index; - -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::future::Future; -use std::sync::{Arc, Mutex}; -use std::task::Poll; - -use futures::TryStreamExt; -use futures::future::BoxFuture; -use hang::timeline::{Position, Record}; -use moq_json::window; -use moq_net::{Timescale, Timestamp, broadcast, track}; -use object_store::ObjectStore; -use tokio::sync::watch; - -use self::index::{Index, Span}; -use crate::store::list::Query; -use crate::{Error, Key, Object, Result, Store}; - -/// Configuration for [`Reader::open`]. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct Config { - /// Each served track's timeline track, from the catalog's `archive` entry. - pub timelines: BTreeMap, - /// Upper bound on cached object bytes shared by every track. - pub cache: u64, -} - -impl Config { - /// Serve the tracks `timelines` indexes with a 64 MiB object cache. - pub fn new(timelines: BTreeMap) -> Self { - Self { - timelines, - cache: 64 * 1024 * 1024, - } - } - - /// Set [`cache`](Self::cache). - pub fn with_cache(mut self, bytes: u64) -> Self { - self.cache = bytes; - self - } -} - -/// Serves an archive through a [`broadcast::Producer`] and follows its timelines. -pub struct Reader { - shared: Arc>, - broadcast: broadcast::Producer, - dynamic: broadcast::Dynamic, - timelines: Vec, -} - -/// One track's timeline, replayed onto its own timeline track. -struct Timeline { - /// The track it indexes. - track: String, - producer: track::Producer, - /// `None` when the timeline, like the track it indexes, is untimed. - timescale: Option, - decoder: window::Decoder, - /// The last timeline segment replayed. - cursor: Option, -} - -struct TimelineRequests { - name: String, - timescale: Option, - dynamic: track::Dynamic, -} - -struct Shared { - store: Store, - index: Mutex, - /// Stored object sequence for every validated timeline group. - timelines: Mutex>>, - /// New timeline handlers, including tracks enrolled after serve starts. - requests: kio::Queue, - cache: quick_cache::sync::Cache, Weight>, - /// Each track's `.info`, which is immutable, so a re-requested track costs no GET. - infos: Mutex>, - /// The tracks a timeline indexes, which are the only ones served. - served: Mutex>, - /// Notified whenever the index changes; holds whether the caller declared the recording ended. - changed: watch::Sender, -} - -impl Reader { - /// Create every timeline track on `broadcast`, then replay each track's retained timeline. - /// - /// Fails if a timeline's `.info` is missing or invalid, a timeline track already exists, or the - /// store cannot list a timeline. - pub async fn open(store: Store, broadcast: &broadcast::Producer, config: Config) -> Result { - let items = usize::try_from(config.cache / (1024 * 1024)) - .unwrap_or(usize::MAX) - .max(16); - let shared = Arc::new(Shared { - store, - index: Mutex::default(), - timelines: Mutex::default(), - requests: kio::Queue::new(), - cache: quick_cache::sync::Cache::with_weighter(items, config.cache, Weight), - infos: Mutex::default(), - served: Mutex::default(), - changed: watch::Sender::new(false), - }); - - let mut reader = Self { - shared, - broadcast: broadcast.clone(), - dynamic: broadcast.dynamic(), - timelines: Vec::new(), - }; - for (track, timeline) in config.timelines { - reader.add(track, timeline).await?; - } - reader.refresh().await?; - Ok(reader) - } - - /// Serve and follow `track`, whose timeline is the track `timeline`, such as one a recording - /// enrolled after this reader opened. Its timeline replays on the next [`refresh`](Self::refresh). - /// - /// Fails if the timeline's `.info` is missing or invalid, or `track` is already served. - pub async fn track(&mut self, track: &str, timeline: &str) -> Result<()> { - self.add(track.to_string(), timeline.to_string()).await - } - - async fn add(&mut self, track: String, timeline: String) -> Result<()> { - if self.shared.served.lock().unwrap().contains(&track) { - return Err(moq_net::Error::Duplicate.into()); - } - let info = track_info(&self.shared.store.get_info(&timeline).await?)?; - let timescale = info.timescale; - let producer = self.broadcast.create_track(timeline.clone(), info)?; - self.shared - .requests - .try_push(TimelineRequests { - name: timeline, - timescale, - dynamic: producer.dynamic(), - }) - .expect("the unbounded handler queue stays open while the reader lives"); - self.shared.served.lock().unwrap().insert(track.clone()); - self.timelines.push(Timeline { - track, - producer, - timescale, - decoder: window::Decoder::new(window::ConsumerConfig::default().with_compression(true)), - cursor: None, - }); - Ok(()) - } - - /// Replay timeline objects committed since the last refresh. - /// - /// Lists each timeline's keys after its last replayed segment, then GETs them in segment order. - /// Each stored group opens with a window checkpoint, so a segment that expired or is malformed - /// is skipped and the next one recovers the retained window, evicting whatever it popped. A - /// malformed newest object is retried on the next refresh. - pub async fn refresh(&mut self) -> Result<()> { - for timeline in &mut self.timelines { - timeline.refresh(&self.shared).await?; - } - Ok(()) - } - - /// Finish every replayed timeline track once the caller knows the recording ended. - /// - /// Following stops, and a group still growing is served as it stands. Groups already committed - /// stay servable through [`serve`](Self::serve). - pub fn finish(self) -> Result<()> { - self.shared.changed.send_replace(true); - for timeline in self.timelines { - timeline.producer.finish()?; - } - Ok(()) - } - - /// Answer track and group requests on the broadcast until it closes. - /// - /// The future is independent of this reader, so it keeps serving after - /// [`finish`](Self::finish); run it on any executor. - pub fn serve(&self) -> impl Future + Send + 'static - where - T: 'static, - { - serve(self.shared.clone(), self.dynamic.clone()) - } -} - -impl Timeline { - async fn refresh(&mut self, shared: &Shared) -> Result<()> { - let name = self.producer.name().to_string(); - let mut query = Query::segments(&name)?; - if let Some(cursor) = self.cursor { - query = query.after(&Key::segments(&name, cursor)?)?; - } - - let entries: Vec<_> = shared.store.list(&query).try_collect().await?; - let mut segments: Vec = entries - .into_iter() - .filter_map(|entry| match entry.key { - Key::Segments { segment, .. } => Some(segment), - _ => None, - }) - .filter(|segment| self.cursor.is_none_or(|cursor| *segment > cursor)) - .collect(); - segments.sort_unstable(); - segments.dedup(); - - for segment in segments { - match shared - .store - .get_segments(&name, segment, self.timescale.is_some()) - .await - { - Ok(object) => self.replay(shared, segment, &object)?, - Err(Error::Store(err)) => return Err(Error::Store(err)), - Err(err) => { - tracing::warn!(timeline = name, segment, %err, "skipping unreadable timeline segment"); - continue; - } - } - self.cursor = Some(segment); - } - - Ok(()) - } - - /// Index one timeline object's groups, then republish each on the timeline track. - fn replay(&mut self, shared: &Shared, segment: u64, object: &Object) -> Result<()> { - let timed = self.timescale.is_some(); - for stored in &object.groups { - let mut group = self.decoder.group(); - let decoded = stored.frames.iter().try_for_each(|frame| group.decode(&frame.payload)); - - // Apply whatever decoded, even from a group that failed partway: the next group's - // checkpoint restates the window regardless. - let mut index = shared.index.lock().unwrap(); - while let Some(event) = group.next_event() { - match event { - window::Event::Push { value, .. } - if value.pts.is_some() != timed || value.duration.is_some() != timed => - { - tracing::warn!( - track = self.track, - sequence = value.sequence, - "ignoring a record whose timing contradicts its timeline" - ); - } - window::Event::Push { index: at, value } if at == value.sequence => index.push(&self.track, &value), - window::Event::Push { index: at, value } => { - tracing::warn!( - track = self.track, - at, - sequence = value.sequence, - "ignoring a misnumbered record" - ); - } - window::Event::Pop(range) | window::Event::Skip(range) => { - for span in index.pop(&self.track, range) { - if let Ok(key) = Key::segments(self.track.clone(), span.sequence) { - shared.cache.remove(&key); - } - } - } - _ => {} - } - } - drop(index); - shared.changed.send_modify(|_| {}); - - if let Err(err) = decoded { - tracing::warn!(group = stored.sequence, %err, "skipping malformed timeline group"); - continue; - } - - shared - .timelines - .lock() - .unwrap() - .entry(self.producer.name().to_string()) - .or_default() - .insert(stored.sequence, segment); - - let mut producer = match self.producer.create_group(stored.sequence.into()) { - Ok(producer) => producer, - Err(moq_net::Error::Duplicate) => continue, - Err(err) => return Err(err.into()), - }; - for frame in &stored.frames { - producer.write_frame(timestamp(frame.timestamp, self.timescale)?, frame.payload.clone())?; - } - producer.finish()?; - } - Ok(()) - } -} - -/// Serve requested tracks until the broadcast closes. -async fn serve(shared: Arc>, mut dynamic: broadcast::Dynamic) { - let mut serving = kio::Tasks::new(); - kio::wait(|waiter| { - while let Poll::Ready(Ok(requests)) = shared.requests.poll_pop(waiter) { - serving.push(task(Box::pin(serve_timeline(shared.clone(), requests)))); - } - loop { - match dynamic.poll_requested_track(waiter) { - Poll::Ready(Ok(request)) => { - let known = shared.served.lock().unwrap().contains(request.name()); - serving.push(task(Box::pin(serve_track(shared.clone(), request, known)))) - } - Poll::Ready(Err(_)) => return Poll::Ready(()), - Poll::Pending => break, - } - } - let _ = serving.poll(waiter); - Poll::Pending - }) - .await -} - -/// Serve a timeline's evicted groups from their original stored objects. -async fn serve_timeline(shared: Arc>, requests: TimelineRequests) { - let mut fills = kio::Tasks::new(); - kio::wait(|waiter| { - loop { - match requests.dynamic.poll_requested_fill(waiter) { - Poll::Ready(Ok(fill)) => { - let (shared, name, timescale) = (shared.clone(), requests.name.clone(), requests.timescale); - fills.push(task(Box::pin(async move { - let mut last = None; - while fill.is_used() - && let Some(sequence) = fill.next() - && last != Some(sequence) - { - last = Some(sequence); - serve_timeline_group(&shared, &name, timescale, &fill, sequence).await; - } - }))); - } - Poll::Ready(Err(_)) => return Poll::Ready(()), - Poll::Pending => break, - } - } - let _ = fills.poll(waiter); - Poll::Pending - }) - .await; -} - -/// Where group `sequence` of `fill` starts. -fn frame_start(fill: &track::Fill, sequence: u64) -> u64 { - let start = fill.start(); - match start.group == sequence { - true => start.frame, - false => 0, - } -} - -async fn serve_timeline_group( - shared: &Shared, - name: &str, - timescale: Option, - fill: &track::Fill, - sequence: u64, -) { - let reject = || fill.drop_groups(sequence..sequence + 1); - let segment = shared - .timelines - .lock() - .unwrap() - .get(name) - .and_then(|groups| groups.get(&sequence)) - .copied(); - let Some(segment) = segment else { - reject(); - return; - }; - let key = match Key::segments(name, segment) { - Ok(key) => key, - Err(_) => { - reject(); - return; - } - }; - let object = shared - .cache - .get_or_insert_async(&key, async { - shared - .store - .get_segments(name, segment, timescale.is_some()) - .await - .map(Arc::new) - }) - .await; - let object = match object { - Ok(object) => object, - Err(err) => { - tracing::warn!(track = name, sequence, %err, "archived timeline group is unavailable"); - reject(); - return; - } - }; - let start = frame_start(fill, sequence); - let Some(stored) = object.groups.iter().find(|group| group.sequence == sequence) else { - reject(); - return; - }; - if object.frame_start != 0 { - reject(); - return; - } - let Ok(mut producer) = fill.accept(sequence, None) else { - return; - }; - if producer.start_at(start).is_err() { - return; - } - for frame in stored.frames.iter().skip(start as usize) { - let Ok(timestamp) = timestamp(frame.timestamp, timescale) else { - let _ = producer.abort(moq_net::Error::NotFound); - return; - }; - if producer.write_frame(timestamp, frame.payload.clone()).is_err() { - return; - } - } - let _ = producer.finish(); -} - -/// Accept a track a timeline indexes, then serve its fills until nobody uses it. -async fn serve_track(shared: Arc>, request: track::Request, known: bool) { - let name = request.name().to_string(); - if !known { - request.reject(moq_net::Error::NotFound); - return; - } - - let cached = shared.infos.lock().unwrap().get(&name).cloned(); - let info = match cached { - Some(info) => info, - None => match shared.store.get_info(&name).await.and_then(|info| track_info(&info)) { - Ok(info) => { - shared.infos.lock().unwrap().insert(name.clone(), info.clone()); - info - } - Err(err) => { - tracing::warn!(track = name, %err, "archived track has no usable .info"); - request.reject(moq_net::Error::NotFound); - return; - } - }, - }; - - let timescale = info.timescale; - let dynamic = request.dynamic(); - let mut demand = dynamic.demand(); - let _producer = request.accept(info); - let mut fills = kio::Tasks::new(); - - kio::wait(|waiter| { - loop { - match dynamic.poll_requested_fill(waiter) { - Poll::Ready(Ok(fill)) => { - let (shared, name) = (shared.clone(), name.clone()); - fills.push(task(Box::pin(async move { - // One group at a time: a fill's groups precede its readers' live edge. - let mut last = None; - while fill.is_used() - && let Some(sequence) = fill.next() - && last != Some(sequence) - { - last = Some(sequence); - serve_group(&shared, &name, timescale, &fill, sequence).await; - } - }))); - } - Poll::Ready(Err(_)) => return Poll::Ready(()), - Poll::Pending => break, - } - } - if fills.poll(waiter).is_pending() { - return Poll::Pending; - } - // Drain the changes so the waiter stays registered, then read where demand settled. - while let Poll::Ready(Ok(Some(_))) = demand.poll_wanted_changed(waiter) {} - match demand.wanted() { - None => Poll::Ready(()), - Some(_) => Poll::Pending, - } - }) - .await -} - -/// Answer one group of a fill from the records holding it, or drop it as never delivered. -/// -/// A group whose records stop short of its end keeps its producer open and grows as later records -/// commit, until one reaches the group's end or the caller finishes the reader. -async fn serve_group( - shared: &Shared, - track: &str, - timescale: Option, - fill: &track::Fill, - sequence: u64, -) { - let start = frame_start(fill, sequence); - let mut changed = shared.changed.subscribe(); - let mut spans = shared.index.lock().unwrap().group(track, sequence); - - // The earliest retained record must hold the requested frame; an expired head is gone. - if spans - .first() - .is_none_or(|span| span.start > Position::new(sequence, start)) - { - fill.drop_groups(sequence..sequence + 1); - return; - } - // Validate the first object before accepting, so an unreadable group is a plain miss. Keep it, - // so a cache too small to hold it costs no second GET. - let timed = timescale.is_some(); - let mut first = match object(shared, track, timed, &spans[0]).await { - Ok(object) => Some((spans[0].sequence, object)), - Err(err) => { - tracing::warn!(track, sequence, %err, "archived group is unavailable"); - fill.drop_groups(sequence..sequence + 1); - return; - } - }; - let Ok(mut producer) = fill.accept(sequence, None) else { - return; - }; - if producer.start_at(start).is_err() { - return; - } - - let mut next = start; - loop { - for span in &spans { - if span.end <= Position::new(sequence, next) { - continue; - } - let loaded = match first.take_if(|(loaded, _)| *loaded == span.sequence) { - Some((_, object)) => Ok(object), - None => object(shared, track, timed, span).await, - }; - let object = match loaded { - Ok(object) if span.start <= Position::new(sequence, next) => object, - Ok(_) => { - let _ = producer.abort(moq_net::Error::NotFound); - return; - } - Err(err) => { - tracing::warn!(track, sequence, %err, "archived group is unavailable"); - let _ = producer.abort(moq_net::Error::NotFound); - return; - } - }; - // The span was validated against its object, so the group is present. - let Some(stored) = object.groups.iter().find(|group| group.sequence == sequence) else { - continue; - }; - let first = match object.groups[0].sequence == sequence { - true => object.frame_start, - false => 0, - }; - for (index, frame) in (first..).zip(&stored.frames) { - if index < next { - continue; - } - let Ok(timestamp) = timestamp(frame.timestamp, timescale) else { - let _ = producer.abort(moq_net::Error::TimestampMismatch); - return; - }; - if let Err(err) = producer.write_frame(timestamp, frame.payload.clone()) { - let _ = producer.abort(err); - return; - } - next = index + 1; - } - } - - let complete = spans.last().is_some_and(|span| span.end.group > sequence); - if complete { - let _ = producer.finish(); - return; - } - // A change that landed while objects loaded is unseen, so the spans above predate it: - // reload before finishing or waiting, or its records would never be served. - if !changed.has_changed().unwrap_or(false) { - // The caller finished the recording, and these spans are its final index. - if *changed.borrow() { - let _ = producer.finish(); - return; - } - if changed.changed().await.is_err() { - let _ = producer.finish(); - return; - } - } - changed.mark_unchanged(); - spans = shared.index.lock().unwrap().group(track, sequence); - } -} - -/// A span's object, from the cache or one GET validated against the span. -async fn object(shared: &Shared, track: &str, timed: bool, span: &Span) -> Result> { - let key = Key::segments(track.to_string(), span.sequence)?; - shared - .cache - .get_or_insert_async(&key, async { - let object = shared.store.get_segments(track, span.sequence, timed).await?; - object.check_span(span.start, span.end)?; - Ok(Arc::new(object)) - }) - .await -} - -fn track_info(info: &crate::Info) -> Result { - let mut track = track::Info::default().with_priority(info.priority); - if let Some(timescale) = info.timescale { - track = track.with_timescale(Timescale::new(timescale).map_err(|_| Error::Timescale(timescale))?); - } - Ok(track) -} - -/// A stored timestamp on its track, which must be timed exactly when the frame is. -fn timestamp(value: Option, timescale: Option) -> Result> { - match (value, timescale) { - (Some(value), Some(timescale)) => Ok(Some(Timestamp::new(value, timescale).map_err(|_| Error::Id(value))?)), - (None, None) => Ok(None), - _ => Err(Error::Timing), - } -} - -/// Adapt a boxed future into a [`kio::Tasks`] task. -fn task(mut future: BoxFuture<'static, ()>) -> impl FnMut(&kio::Waiter) -> Poll<()> + Send { - move |waiter| waiter.poll_future(future.as_mut()) -} - -/// Weighs a cached object by its payload bytes plus a small per-frame overhead. -#[derive(Clone)] -struct Weight; - -impl quick_cache::Weighter> for Weight { - fn weight(&self, _key: &Key, object: &Arc) -> u64 { - let frames = object.groups.iter().flat_map(|group| &group.frames); - frames.map(|frame| frame.payload.len() as u64 + 32).sum::() + 64 - } -} - -#[cfg(test)] -#[allow(clippy::disallowed_methods, clippy::disallowed_types)] -mod tests; diff --git a/rs/moq-archive/src/reader/tests.rs b/rs/moq-archive/src/reader/tests.rs deleted file mode 100644 index d3bd64b6adb..00000000000 --- a/rs/moq-archive/src/reader/tests.rs +++ /dev/null @@ -1,868 +0,0 @@ -use std::collections::HashMap; -use std::time::Duration; - -use bytes::Bytes; -use hang::timeline::{Position, Record}; -use moq_json::window; -use moq_net::{broadcast, group}; -use object_store::ObjectStoreExt; - -use super::*; -use crate::Info; -use crate::mock::{Mock, Op}; -use crate::segment::{Frame, Group}; - -fn timeline(track: &str) -> String { - hang::timeline::default_name(track) -} - -/// One track's timeline encoder, as a recording writer drives it. -struct Encoder { - encoder: window::Encoder, - /// Next timeline group sequence. - group: u64, - /// Next record sequence. - record: u64, -} - -/// Writes archive objects the way a recording writer lays them out. -struct Archive { - store: Store, - op_ratio: u32, - encoders: HashMap, -} - -impl Archive { - async fn new() -> Self { - Self::with_op_ratio(0).await - } - - /// `op_ratio` 0 makes every window edit its own checkpoint group. - async fn with_op_ratio(op_ratio: u32) -> Self { - Self { - store: Store::new(Mock::memory(), "rec"), - op_ratio, - encoders: HashMap::new(), - } - } - - async fn encoder(&mut self, track: &str) -> &mut Encoder { - if !self.encoders.contains_key(track) { - self.store - .put_info(track, &Info::new(1, Some(1000)).unwrap()) - .await - .unwrap(); - self.store - .put_info(&timeline(track), &Info::new(0, Some(1000)).unwrap()) - .await - .unwrap(); - let config = window::ProducerConfig::default() - .with_compression(true) - .with_op_ratio(self.op_ratio); - self.encoders.insert( - track.to_string(), - Encoder { - encoder: window::Encoder::new(config), - group: 0, - record: 0, - }, - ); - } - self.encoders.get_mut(track).unwrap() - } - - /// The next record of `track`, spanning `start..end` where every group holds `frames` frames. - /// Stores its object unless `store` is false. - async fn record(&mut self, track: &str, start: Position, end: Position, frames: u64) -> Record { - let encoder = self.encoder(track).await; - let sequence = encoder.record; - encoder.record += 1; - let record = Record::new(sequence, start.group * 100, 100, start, end); - - let last = match end.frame { - 0 => end.group - 1, - _ => end.group, - }; - let groups = (start.group..=last) - .map(|group| { - let first = if group == start.group { start.frame } else { 0 }; - let stop = if group == end.group { end.frame } else { frames }; - Group { - sequence: group, - frames: (first..stop).map(|i| frame(track, group, i)).collect(), - } - }) - .collect(); - let object = Object { - frame_start: start.frame, - groups, - }; - self.store.put_segments(track, sequence, &object).await.unwrap(); - record - } - - /// Store and commit a record of whole groups `groups`, each holding `frames` frames. - async fn add(&mut self, track: &str, groups: std::ops::Range, frames: u64) -> Record { - let record = self - .record( - track, - Position::group(groups.start), - Position::group(groups.end), - frames, - ) - .await; - self.commit(track, &record, 0).await; - record - } - - /// Push `record`, pop `pop` records, and return the resulting timeline groups. - async fn edit(&mut self, track: &str, record: &Record, pop: u64) -> Vec { - let encoder = self.encoder(track).await; - let mut frames = Vec::new(); - let pending = encoder.encoder.push(record).unwrap(); - frames.push((pending.keyframe, pending.payload.clone())); - pending.commit(); - if let Some(pending) = encoder.encoder.pop(pop).unwrap() { - frames.push((pending.keyframe, pending.payload.clone())); - pending.commit(); - } - - let mut groups: Vec = Vec::new(); - for (keyframe, payload) in frames { - if keyframe { - encoder.group += 1; - } - if groups.last().is_none_or(|group| group.sequence != encoder.group - 1) { - groups.push(Group { - sequence: encoder.group - 1, - frames: Vec::new(), - }); - } - let timestamp = record.pts; - groups.last_mut().unwrap().frames.push(Frame { timestamp, payload }); - } - groups - } - - async fn timeline(&self, track: &str, segment: u64, groups: Vec) { - self.store - .put_segments(&timeline(track), segment, &Object::new(groups)) - .await - .unwrap(); - } - - /// Commit `record` as its track's timeline segment, popping `pop` older records. - async fn commit(&mut self, track: &str, record: &Record, pop: u64) { - let groups = self.edit(track, record, pop).await; - self.timeline(track, record.sequence, groups).await; - } - - async fn raw(&self, key: &Key, bytes: &'static [u8]) { - let path = self.store.path(key).unwrap(); - self.store - .inner() - .put(&path, Bytes::from_static(bytes).into()) - .await - .unwrap(); - } - - fn config(&self) -> Config { - Config::new( - self.encoders - .keys() - .map(|track| (track.clone(), timeline(track))) - .collect(), - ) - } -} - -fn frame(track: &str, sequence: u64, index: u64) -> Frame { - Frame { - timestamp: Some(sequence * 100 + index), - payload: Bytes::from(format!("{track}/{sequence}/{index}")), - } -} - -async fn open(archive: &Archive) -> (broadcast::Producer, Reader) { - open_with(archive, archive.config()).await -} - -async fn open_with(archive: &Archive, config: Config) -> (broadcast::Producer, Reader) { - let broadcast = broadcast::Info::new().produce(); - let reader = Reader::open(archive.store.clone(), &broadcast, config).await.unwrap(); - tokio::spawn(reader.serve()); - (broadcast, reader) -} - -/// FETCH one group starting at `frame_start`, returning each frame's timestamp and payload. -async fn fetch( - broadcast: &broadcast::Producer, - track: &str, - sequence: u64, - frame_start: u64, -) -> std::result::Result, moq_net::Error> { - let track = broadcast.consume().track(track)?; - let options = group::Fetch::default().with_frame_start(frame_start); - let mut group = track.fetch_group(sequence, options).await?; - let mut frames = Vec::new(); - while let Some(frame) = group.read_frame().await? { - frames.push((frame.timestamp.unwrap().value(), frame.payload)); - } - Ok(frames) -} - -fn expected(track: &str, sequence: u64, frames: std::ops::Range) -> Vec<(u64, Bytes)> { - frames - .map(|i| { - let frame = frame(track, sequence, i); - (frame.timestamp.unwrap(), frame.payload) - }) - .collect() -} - -fn not_found(result: std::result::Result, moq_net::Error>) { - assert!(matches!(result, Err(moq_net::Error::NotFound)), "{result:?}"); -} - -#[tokio::test] -async fn fetch_replays_original_groups() { - let mut archive = Archive::new().await; - archive.add("video", 0..2, 3).await; - archive.add("video", 2..3, 2).await; - archive.add("audio", 0..2, 1).await; - archive.add("audio", 5..6, 2).await; - - let (broadcast, _reader) = open(&archive).await; - - assert_eq!( - fetch(&broadcast, "video", 0, 0).await.unwrap(), - expected("video", 0, 0..3) - ); - assert_eq!( - fetch(&broadcast, "video", 2, 0).await.unwrap(), - expected("video", 2, 0..2) - ); - assert_eq!( - fetch(&broadcast, "audio", 5, 0).await.unwrap(), - expected("audio", 5, 0..2) - ); - - // A requested frame_start keeps the original frame indices and timestamps. - let tail = fetch(&broadcast, "video", 1, 1).await.unwrap(); - assert_eq!(tail, expected("video", 1, 1..3)); - - let track = broadcast.consume().track("video").unwrap(); - let info = track.query().await.unwrap(); - assert_eq!(info.priority, 1); - assert_eq!(info.timescale.unwrap().as_u64(), 1000); -} - -#[tokio::test] -async fn a_group_split_across_records_is_served_whole() { - let mut archive = Archive::new().await; - let head = archive - .record("video", Position::group(7), Position::new(7, 3), 5) - .await; - archive.commit("video", &head, 0).await; - let tail = archive - .record("video", Position::new(7, 3), Position::group(9), 5) - .await; - archive.commit("video", &tail, 0).await; - - let (broadcast, _reader) = open(&archive).await; - assert_eq!( - fetch(&broadcast, "video", 7, 0).await.unwrap(), - expected("video", 7, 0..5) - ); - assert_eq!( - fetch(&broadcast, "video", 7, 4).await.unwrap(), - expected("video", 7, 4..5) - ); - assert_eq!( - fetch(&broadcast, "video", 8, 0).await.unwrap(), - expected("video", 8, 0..5) - ); -} - -#[tokio::test] -async fn an_open_group_grows_as_records_commit() { - let mut archive = Archive::new().await; - let head = archive.record("log", Position::group(0), Position::new(0, 2), 0).await; - archive.commit("log", &head, 0).await; - - let (broadcast, mut reader) = open(&archive).await; - let track = broadcast.consume().track("log").unwrap(); - let mut group = tokio::time::timeout(Duration::from_secs(1), track.fetch_group(0, group::Fetch::default())) - .await - .expect("fetch") - .unwrap(); - for index in 0..2 { - let frame = group.read_frame().await.unwrap().unwrap(); - assert_eq!(frame.payload, frame_payload("log", 0, index)); - } - let pending = tokio::time::timeout(Duration::from_millis(50), group.read_frame()).await; - assert!(pending.is_err(), "the group waits for its next record"); - - let more = archive.record("log", Position::new(0, 2), Position::new(0, 3), 0).await; - archive.commit("log", &more, 0).await; - reader.refresh().await.unwrap(); - let frame = group.read_frame().await.unwrap().unwrap(); - assert_eq!(frame.payload, frame_payload("log", 0, 2)); - - // Finishing the reader ends the group where the recording stopped. - reader.finish().unwrap(); - assert!(group.read_frame().await.unwrap().is_none()); -} - -/// A refresh that lands while a growing group loads an object is still served, not swallowed -/// by the wait for the next one. -#[tokio::test(start_paused = true)] -async fn a_refresh_during_an_object_load_is_served() { - let mut archive = Archive::new().await; - let head = archive.record("log", Position::group(0), Position::new(0, 2), 0).await; - archive.commit("log", &head, 0).await; - - let (broadcast, mut reader) = open(&archive).await; - let mock = archive.store.inner().clone(); - mock.take(); - let gate = mock.stall_gets("log/segments/"); - let track = broadcast.consume().track("log").unwrap(); - let fetch = tokio::spawn(async move { track.fetch_group(0, group::Fetch::default()).await }); - - // Wait until the head object's GET is in flight, then commit the tail behind it. - while !mock.gets().iter().any(|path| path.contains("log/segments/")) { - tokio::task::yield_now().await; - } - let more = archive.record("log", Position::new(0, 2), Position::new(0, 3), 0).await; - archive.commit("log", &more, 0).await; - reader.refresh().await.unwrap(); - gate.add_permits(16); - - let mut group = fetch.await.unwrap().unwrap(); - for index in 0..3 { - let frame = tokio::time::timeout(Duration::from_secs(1), group.read_frame()) - .await - .expect("the refreshed tail is served") - .unwrap() - .unwrap(); - assert_eq!(frame.payload, frame_payload("log", 0, index)); - } -} - -fn frame_payload(track: &str, sequence: u64, index: u64) -> Bytes { - frame(track, sequence, index).payload -} - -#[tokio::test] -async fn an_expired_head_is_not_found() { - let mut archive = Archive::new().await; - let head = archive - .record("video", Position::group(7), Position::new(7, 3), 5) - .await; - archive.commit("video", &head, 0).await; - let tail = archive - .record("video", Position::new(7, 3), Position::group(8), 5) - .await; - archive.commit("video", &tail, 1).await; - - let (broadcast, _reader) = open(&archive).await; - not_found(fetch(&broadcast, "video", 7, 0).await); - assert_eq!( - fetch(&broadcast, "video", 7, 3).await.unwrap(), - expected("video", 7, 3..5) - ); -} - -#[tokio::test] -async fn requests_download_only_their_object() { - let mut archive = Archive::new().await; - archive.add("video", 0..1, 1).await; - archive.add("audio", 0..3, 1).await; - - let (broadcast, _reader) = open(&archive).await; - archive.store.inner().take(); - - for sequence in 0..3 { - assert_eq!( - fetch(&broadcast, "audio", sequence, 0).await.unwrap(), - expected("audio", sequence, 0..1) - ); - } - - let gets = archive.store.inner().gets(); - assert_eq!( - gets, - vec![ - "rec/audio/.info".to_string(), - "rec/audio/segments/0000000000000000000".to_string(), - ], - "adjacent groups reuse one GET and never touch video" - ); -} - -#[tokio::test] -async fn unadvertised_or_unreadable_groups_are_not_found() { - let mut archive = Archive::new().await; - archive.add("audio", 0..2, 1).await; - archive.add("audio", 4..5, 1).await; - // The object holds groups 0 and 1, but its record names group 2 too. - let mismatched = archive.record("video", Position::group(0), Position::group(2), 1).await; - archive - .commit( - "video", - &Record::new(0, 0, 100, Position::group(0), Position::group(3)), - 0, - ) - .await; - let bad = archive.record("bad", Position::group(0), Position::group(1), 1).await; - archive.raw(&Key::segments("bad", 0).unwrap(), b"\x02garbage").await; - archive.commit("bad", &bad, 0).await; - let missing = Record::new(0, 0, 100, Position::group(0), Position::group(1)); - archive.encoder("missing").await; - archive.commit("missing", &missing, 0).await; - assert_eq!(mismatched.end, Position::group(2)); - - let (broadcast, _reader) = open(&archive).await; - - not_found(fetch(&broadcast, "audio", 2, 0).await); // between records - not_found(fetch(&broadcast, "audio", 5, 0).await); // past the last record - not_found(fetch(&broadcast, "video", 0, 0).await); // table disagrees with the record - not_found(fetch(&broadcast, "bad", 0, 0).await); // malformed envelope - not_found(fetch(&broadcast, "missing", 0, 0).await); // no object - not_found(fetch(&broadcast, "chat", 0, 0).await); // a track no timeline indexes - - // Siblings remain usable. - assert_eq!( - fetch(&broadcast, "audio", 4, 0).await.unwrap(), - expected("audio", 4, 0..1) - ); -} - -#[tokio::test] -async fn refresh_follows_new_records_and_pops() { - let mut archive = Archive::new().await; - archive.add("video", 0..2, 1).await; - - let (broadcast, mut reader) = open(&archive).await; - assert!(fetch(&broadcast, "video", 0, 0).await.is_ok()); - - let next = archive.record("video", Position::group(2), Position::group(3), 1).await; - // Refreshing before the commit finds nothing new; Not Found is not finality. - reader.refresh().await.unwrap(); - not_found(fetch(&broadcast, "video", 2, 0).await); - - archive.commit("video", &next, 1).await; - reader.refresh().await.unwrap(); - assert_eq!( - fetch(&broadcast, "video", 2, 0).await.unwrap(), - expected("video", 2, 0..1) - ); - - // The popped record's groups are gone, although its object is still stored and was cached. - archive.store.inner().take(); - not_found(fetch(&broadcast, "video", 1, 0).await); - assert_eq!(archive.store.inner().gets(), Vec::::new()); -} - -/// A track the recording enrolled after the reader opened is found by listing the store again, -/// then served and followed like the rest. -#[tokio::test] -async fn a_track_enrolled_after_opening_is_followed() { - let mut archive = Archive::new().await; - archive.add("audio", 0..2, 1).await; - - let (broadcast, mut reader) = open(&archive).await; - not_found(fetch(&broadcast, "video", 0, 0).await); - - archive.add("video", 0..2, 2).await; - let timelines = archive.store.timelines().await.unwrap(); - assert_eq!(timelines.keys().collect::>(), ["audio", "video"]); - reader.track("video", &timelines["video"]).await.unwrap(); - assert!( - reader.track("video", &timelines["video"]).await.is_err(), - "a served track is refused" - ); - reader.refresh().await.unwrap(); - - assert_eq!( - fetch(&broadcast, "video", 1, 0).await.unwrap(), - expected("video", 1, 0..2) - ); - let timeline = broadcast.consume().track(&timeline("video")).unwrap(); - assert!(timeline.subscribe(None).await.is_ok(), "the late timeline replays"); -} - -/// A timeline directory without its `.info`, such as one interrupted mid-enrollment, isn't -/// advertised, so following the recording doesn't fail on it. -#[tokio::test] -async fn a_timeline_without_info_is_not_listed() { - let mut archive = Archive::new().await; - archive.add("audio", 0..2, 1).await; - archive - .raw(&Key::segments(timeline("video"), 0).unwrap(), b"partial") - .await; - - let timelines = archive.store.timelines().await.unwrap(); - assert_eq!(timelines.keys().collect::>(), ["audio"]); -} - -#[tokio::test] -async fn a_missing_timeline_segment_recovers_from_the_next_checkpoint() { - let mut archive = Archive::new().await; - archive.add("video", 0..1, 1).await; - // Record 1 is committed to the window but its timeline object is unreadable. - let lost = archive.record("video", Position::group(1), Position::group(2), 1).await; - archive.edit("video", &lost, 0).await; - archive - .raw(&Key::segments(timeline("video"), 1).unwrap(), b"\x02") - .await; - archive.add("video", 2..3, 1).await; - - let (broadcast, _reader) = open(&archive).await; - - // Record 2's checkpoint restates record 1, so its group is still served. - for sequence in 0..3 { - assert_eq!( - fetch(&broadcast, "video", sequence, 0).await.unwrap(), - expected("video", sequence, 0..1) - ); - } -} - -#[tokio::test] -async fn a_missing_tail_is_retried_on_the_next_refresh() { - let mut archive = Archive::new().await; - for group in 0..3 { - archive.add("video", group..group + 1, 1).await; - } - // Listed, but not yet readable. - archive.store.inner().hide_gets("segments/0000000000000000002"); - - let (broadcast, mut reader) = open(&archive).await; - assert!(fetch(&broadcast, "video", 1, 0).await.is_ok()); - not_found(fetch(&broadcast, "video", 2, 0).await); - - archive.store.inner().heal(); - archive.store.inner().take(); - reader.refresh().await.unwrap(); - assert_eq!( - archive.store.inner().take(), - [ - Op::List { - prefix: "rec/video%2Etimeline%2Ez/segments".to_string(), - offset: Some("rec/video%2Etimeline%2Ez/segments/0000000000000000001".to_string()), - }, - Op::Get("rec/video%2Etimeline%2Ez/segments/0000000000000000002".to_string()), - ], - "the cursor stays before the missing tail" - ); - assert_eq!( - fetch(&broadcast, "video", 2, 0).await.unwrap(), - expected("video", 2, 0..1) - ); -} - -#[tokio::test] -async fn following_lists_only_new_timeline_keys() { - let mut archive = Archive::new().await; - archive.add("video", 0..1, 1).await; - let (broadcast, mut reader) = open(&archive).await; - - for group in 1..4 { - // A media object stored ahead of its commit is invisible until the timeline names it. - let record = archive - .record("video", Position::group(group), Position::group(group + 1), 1) - .await; - reader.refresh().await.unwrap(); - not_found(fetch(&broadcast, "video", group, 0).await); - - archive.commit("video", &record, 0).await; - archive.store.inner().take(); - reader.refresh().await.unwrap(); - let previous = format!("rec/video%2Etimeline%2Ez/segments/{:019}", group - 1); - assert_eq!( - archive.store.inner().take(), - [ - Op::List { - prefix: "rec/video%2Etimeline%2Ez/segments".to_string(), - offset: Some(previous), - }, - Op::Get(format!("rec/video%2Etimeline%2Ez/segments/{group:019}")), - ], - "following record {group} touches no media listing" - ); - assert_eq!( - fetch(&broadcast, "video", group, 0).await.unwrap(), - expected("video", group, 0..1) - ); - } -} - -#[tokio::test] -async fn an_unordered_listing_replays_in_segment_order() { - let mut archive = Archive::new().await; - archive.store.inner().unordered(); - for group in 0..4 { - let record = archive - .record("video", Position::group(group), Position::group(group + 1), 1) - .await; - archive.commit("video", &record, u64::from(group >= 2)).await; - } - - let (broadcast, mut reader) = open(&archive).await; - - // The cursor is the newest segment, not the last one listed. - let record = archive.record("video", Position::group(4), Position::group(5), 1).await; - archive.commit("video", &record, 1).await; - archive.store.inner().take(); - reader.refresh().await.unwrap(); - assert_eq!( - archive.store.inner().gets(), - ["rec/video%2Etimeline%2Ez/segments/0000000000000000004"] - ); - - for group in 0..3 { - not_found(fetch(&broadcast, "video", group, 0).await); - } - for group in 3..5 { - assert_eq!( - fetch(&broadcast, "video", group, 0).await.unwrap(), - expected("video", group, 0..1) - ); - } -} - -#[tokio::test] -async fn a_track_without_usable_info_is_not_found() { - let mut archive = Archive::new().await; - archive.add("audio", 0..1, 1).await; - archive.add("bare", 0..1, 1).await; - archive.add("future", 0..1, 1).await; - let path = archive.store.path(&Key::info("bare").unwrap()).unwrap(); - archive.store.inner().delete(&path).await.unwrap(); - let path = archive.store.path(&Key::info("future").unwrap()).unwrap(); - archive.store.inner().delete(&path).await.unwrap(); - archive - .raw( - &Key::info("future").unwrap(), - br#"{"version":3,"priority":0,"timescale":1000}"#, - ) - .await; - - let (broadcast, _reader) = open(&archive).await; - for track in ["bare", "future"] { - not_found(fetch(&broadcast, track, 0, 0).await); - } - assert_eq!( - fetch(&broadcast, "audio", 0, 0).await.unwrap(), - expected("audio", 0, 0..1) - ); -} - -#[tokio::test] -async fn multi_frame_timeline_groups_decode() { - let mut archive = Archive::with_op_ratio(64).await; - let first = archive.record("video", Position::group(0), Position::group(1), 1).await; - let second = archive.record("video", Position::group(1), Position::group(2), 1).await; - let mut groups = archive.edit("video", &first, 0).await; - let ops = archive.edit("video", &second, 0).await; - assert!( - ops[0].sequence == groups[0].sequence, - "the push stays in the open group" - ); - groups[0].frames.extend(ops.into_iter().flat_map(|group| group.frames)); - archive.timeline("video", 0, groups).await; - - let (broadcast, _reader) = open(&archive).await; - assert!(fetch(&broadcast, "video", 1, 0).await.is_ok()); -} - -#[tokio::test] -async fn timeline_tracks_are_republished_and_finished_on_request() { - let mut archive = Archive::new().await; - archive.add("video", 0..1, 1).await; - archive.add("video", 1..2, 1).await; - archive.add("audio", 0..1, 1).await; - - let (broadcast, reader) = open(&archive).await; - let track = broadcast.consume().track(&timeline("video")).unwrap(); - let subscriber = track.subscribe(None).await.unwrap(); - let mut timeline = - window::Consumer::::new(subscriber, window::ConsumerConfig::default().with_compression(true)); - - reader.finish().unwrap(); - - let mut records = Vec::new(); - while let Some(event) = timeline.next().await.unwrap() { - if let window::Event::Push { value, .. } = event { - records.push(value.sequence); - } - } - assert_eq!(records, vec![0, 1]); -} - -/// A record whose timing contradicts its timeline is not indexed, though its object is stored. -#[tokio::test] -async fn a_record_contradicting_its_timeline_is_not_indexed() { - let mut archive = Archive::new().await; - archive.add("video", 0..1, 1).await; - let stored = archive.record("video", Position::group(1), Position::group(2), 1).await; - let untimed = Record::untimed(stored.sequence, stored.start, stored.end); - let mut groups = archive.edit("video", &untimed, 0).await; - // The timeline envelope stays timed, as its `.info` declares. - for frame in groups.iter_mut().flat_map(|group| &mut group.frames) { - frame.timestamp = Some(100); - } - archive.timeline("video", untimed.sequence, groups).await; - - let (broadcast, _reader) = open(&archive).await; - assert_eq!( - fetch(&broadcast, "video", 0, 0).await.unwrap(), - expected("video", 0, 0..1) - ); - not_found(fetch(&broadcast, "video", 1, 0).await); -} - -#[tokio::test] -async fn open_requires_every_timeline_info() { - let store = Store::new(Mock::memory(), "rec"); - let broadcast = broadcast::Info::new().produce(); - let config = Config::new([("video".to_string(), timeline("video"))].into()); - let result = Reader::open(store, &broadcast, config).await; - assert!(matches!(result, Err(Error::NotFound(_)))); -} - -#[tokio::test(start_paused = true)] -async fn evicted_timeline_groups_remain_fetchable() { - let mut archive = Archive::new().await; - archive.add("video", 0..1, 1).await; - archive.add("video", 1..2, 1).await; - let mut info = broadcast::Info::new(); - info.pool = moq_net::cache::Pool::new(moq_net::cache::Config::default().with_expiry(Duration::from_millis(100))); - let pool = info.pool.clone(); - let broadcast = info.produce(); - let reader = Reader::open(archive.store.clone(), &broadcast, archive.config()) - .await - .unwrap(); - let serve = tokio::spawn(reader.serve()); - let expected = fetch(&broadcast, &timeline("video"), 0, 0).await.unwrap(); - let now = moq_net::time::Instant::now(); - pool.gc(now, &kio::Waiter::noop()); - pool.gc(now + Duration::from_secs(1), &kio::Waiter::noop()); - assert_eq!(fetch(&broadcast, &timeline("video"), 0, 0).await.unwrap(), expected); - // Past the end of an evicted complete group is EOF, as it is for a cached one. - pool.gc(now + Duration::from_secs(2), &kio::Waiter::noop()); - pool.gc(now + Duration::from_secs(3), &kio::Waiter::noop()); - assert!( - fetch(&broadcast, &timeline("video"), 0, expected.len() as u64 + 1) - .await - .unwrap() - .is_empty() - ); - serve.abort(); - let _ = serve.await; -} - -#[tokio::test(start_paused = true)] -async fn enrolled_timeline_fetch_survives_finish() { - let mut archive = Archive::new().await; - archive.add("video", 0..1, 1).await; - let mut info = broadcast::Info::new(); - info.pool = moq_net::cache::Pool::new(moq_net::cache::Config::default().with_expiry(Duration::from_secs(30))); - let broadcast = info.produce(); - let mut reader = Reader::open(archive.store.clone(), &broadcast, archive.config()) - .await - .unwrap(); - let serving = tokio::spawn(reader.serve()); - archive.add("audio", 0..1, 1).await; - archive.add("audio", 1..2, 1).await; - reader.track("audio", &timeline("audio")).await.unwrap(); - reader.refresh().await.unwrap(); - let expected = fetch(&broadcast, &timeline("audio"), 0, 0).await.unwrap(); - reader.finish().unwrap(); - let now = moq_net::time::Instant::now(); - broadcast.info().pool.gc(now, &kio::Waiter::noop()); - broadcast - .info() - .pool - .gc(now + Duration::from_secs(31), &kio::Waiter::noop()); - assert_eq!(fetch(&broadcast, &timeline("audio"), 0, 0).await.unwrap(), expected); - let missing = fetch(&broadcast, &timeline("audio"), 2, 0).await; - assert!( - matches!(missing, Err(moq_net::Error::NotFound)), - "unknown timeline group: {missing:?}" - ); - // Complete groups retain their known end, so a read past it is EOF. - assert!( - fetch(&broadcast, &timeline("audio"), 0, expected.len() as u64 + 1) - .await - .unwrap() - .is_empty() - ); - serving.abort(); - let _ = serving.await; -} - -#[tokio::test(start_paused = true)] -#[ignore = "run explicitly to measure timeline FETCH fanout"] -async fn benchmark_timeline_fetch_fanout() { - for tracks in [1, 16, 128] { - for readers in [1, 16, 128] { - let mut archive = Archive::new().await; - for index in 0..tracks { - let name = format!("video-{index}"); - archive.add(&name, 0..1, 1).await; - archive.add(&name, 1..2, 1).await; - } - let mut info = broadcast::Info::new(); - info.pool = - moq_net::cache::Pool::new(moq_net::cache::Config::default().with_expiry(Duration::from_secs(30))); - let broadcast = info.produce(); - let reader = Reader::open(archive.store.clone(), &broadcast, archive.config()) - .await - .unwrap(); - let serving = tokio::spawn(reader.serve()); - let name = timeline("video-0"); - let mut now = moq_net::time::Instant::now(); - broadcast.info().pool.gc(now, &kio::Waiter::noop()); - let mut elapsed = Duration::ZERO; - for _ in 0..20 { - // One sweep dates recent reads; the next expires them. - for _ in 0..2 { - now += Duration::from_secs(31); - broadcast.info().pool.gc(now, &kio::Waiter::noop()); - } - let subscription = moq_net::track::Subscription::default() - .with_groups(0..) - .with_max_delay(Duration::from_secs(60)); - let mut retained = broadcast - .consume() - .track(&name) - .unwrap() - .subscribe(subscription) - .await - .unwrap() - .ordered(); - assert_eq!( - retained.next_group().await.unwrap().unwrap().sequence, - 1, - "benchmark must fetch an evicted group" - ); - let started = std::time::Instant::now(); - let results = futures::future::join_all((0..readers).map(|_| fetch(&broadcast, &name, 0, 0))).await; - elapsed += started.elapsed(); - assert!( - results - .iter() - .all(|result| result.as_ref().is_ok_and(|frames| !frames.is_empty())) - ); - } - println!( - "timeline-fetch tracks={tracks} readers={readers} mean_us={:.1}", - elapsed.as_secs_f64() * 1_000_000.0 / 20.0 - ); - serving.abort(); - let _ = serving.await; - } - } -} diff --git a/rs/moq-archive/src/segment.rs b/rs/moq-archive/src/segment.rs deleted file mode 100644 index 9f98a119719..00000000000 --- a/rs/moq-archive/src/segment.rs +++ /dev/null @@ -1,626 +0,0 @@ -use bytes::{Buf, BufMut, Bytes, BytesMut}; -use hang::timeline::Position; -use moq_net::varint; - -use crate::path::check_id; -use crate::{Error, Result, VERSION}; - -/// One group's stored frames in a segment object, in sequence order. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Group { - /// Group sequence number. - pub sequence: u64, - /// Frames in their original order within the group. - pub frames: Vec, -} - -/// One frame's timestamp and original payload. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Frame { - /// Absolute timestamp in the track's timescale, or `None` on an untimed track. - pub timestamp: Option, - /// Original frame payload, including any group-scoped compression. - pub payload: Bytes, -} - -/// A versioned group/frame table followed by concatenated payload bytes. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Object { - /// The index of the first group's first stored frame within that group; later groups start at - /// frame zero. - pub frame_start: u64, - /// Groups in strictly ascending sequence order. - pub groups: Vec, -} - -impl Object { - /// An object whose first group starts at frame zero. - pub fn new(groups: Vec) -> Self { - Self { frame_start: 0, groups } - } - - /// The position of the first stored frame. - pub fn start(&self) -> Result { - validate(&self.groups)?; - Ok(Position::new(self.groups[0].sequence, self.frame_start)) - } - - /// Require this object to hold exactly the frames `start..end` of a record, with no group - /// missing or empty. - pub fn check_span(&self, start: Position, end: Position) -> Result<()> { - if self.start()? != start { - return Err(Error::Span); - } - for (expected, group) in (start.group..).zip(&self.groups) { - if group.sequence != expected || group.frames.is_empty() { - return Err(Error::Span); - } - } - let last = self.groups.last().expect("validated as non-empty"); - let first = match self.groups.len() { - 1 => self.frame_start, - _ => 0, - }; - let stop = first.checked_add(last.frames.len() as u64).ok_or(Error::Overflow)?; - let exact = match end.frame { - 0 => end.group == last.sequence + 1, - frame => end.group == last.sequence && frame == stop, - }; - if !exact { - return Err(Error::Span); - } - Ok(()) - } - - /// Encode the binary envelope. The first sequence is absolute; later ones are `current - previous - 1`. - /// - /// Frame timestamps are stored only when the frames have them, so every frame must agree. - pub fn encode(&self) -> Result { - validate(&self.groups)?; - - let mut table = BytesMut::new(); - write_varint(&mut table, VERSION)?; - write_varint(&mut table, self.frame_start)?; - write_varint(&mut table, self.groups.len() as u64)?; - - let mut payload = BytesMut::new(); - let mut prev = None; - for group in &self.groups { - let delta = match prev { - None => group.sequence, - Some(previous) => group - .sequence - .checked_sub(previous) - .ok_or(Error::Overflow)? - .checked_sub(1) - .ok_or(Error::Overflow)?, - }; - prev = Some(group.sequence); - write_varint(&mut table, delta)?; - write_varint(&mut table, group.frames.len() as u64)?; - for frame in &group.frames { - let offset = u64::try_from(payload.len()).map_err(|_| Error::Overflow)?; - let length = u64::try_from(frame.payload.len()).map_err(|_| Error::Overflow)?; - if let Some(timestamp) = frame.timestamp { - write_varint(&mut table, timestamp)?; - } - write_varint(&mut table, offset)?; - write_varint(&mut table, length)?; - payload.extend_from_slice(&frame.payload); - } - } - - table.extend_from_slice(&payload); - Ok(table.freeze()) - } - - /// Decode a complete table before slicing any payload, and refuse an unknown version. - /// - /// `timed` is whether the track's `.info` declares a timescale, which says whether the table - /// stores a timestamp per frame. - pub fn decode(mut buf: impl Buf, timed: bool) -> Result { - let version = read_varint(&mut buf)?; - if version != VERSION { - return Err(Error::Version(version)); - } - - let frame_start = read_varint(&mut buf)?; - let group_count = read_count(&mut buf, 2)?; - if group_count == 0 { - return Err(Error::Empty); - } - - struct Entry { - sequence: u64, - frames: Vec<(Option, u64, u64)>, - } - - let mut entries = Vec::new(); - let mut prev: Option = None; - for i in 0..group_count { - let delta = read_varint(&mut buf)?; - let sequence = if i == 0 { - check_id(delta)? - } else { - let previous = prev.unwrap(); - let sequence = previous - .checked_add(1) - .ok_or(Error::Overflow)? - .checked_add(delta) - .ok_or(Error::Overflow)?; - check_id(sequence)? - }; - if let Some(previous) = prev - && sequence <= previous - { - return Err(Error::Sequence); - } - prev = Some(sequence); - - let frame_count = read_count(&mut buf, if timed { 3 } else { 2 })?; - let mut frames = Vec::new(); - for _ in 0..frame_count { - let timestamp = match timed { - true => Some(check_id(read_varint(&mut buf)?)?), - false => None, - }; - let offset = read_varint(&mut buf)?; - let length = read_varint(&mut buf)?; - frames.push((timestamp, offset, length)); - } - entries.push(Entry { sequence, frames }); - } - - let payload = buf.copy_to_bytes(buf.remaining()); - let payload_len = u64::try_from(payload.len()).map_err(|_| Error::Overflow)?; - - let mut expected = 0u64; - let mut groups = Vec::with_capacity(entries.len()); - for entry in entries { - let mut frames = Vec::with_capacity(entry.frames.len()); - for (timestamp, offset, length) in entry.frames { - if offset != expected { - return Err(Error::Table); - } - let end = offset.checked_add(length).ok_or(Error::Overflow)?; - if end > payload_len { - return Err(Error::Table); - } - let start = usize::try_from(offset).map_err(|_| Error::Overflow)?; - let stop = usize::try_from(end).map_err(|_| Error::Overflow)?; - frames.push(Frame { - timestamp, - payload: payload.slice(start..stop), - }); - expected = end; - } - groups.push(Group { - sequence: entry.sequence, - frames, - }); - } - if expected != payload_len { - return Err(Error::Table); - } - - Ok(Self { frame_start, groups }) - } -} - -fn validate(groups: &[Group]) -> Result<()> { - if groups.is_empty() { - return Err(Error::Empty); - } - let mut prev = None; - let mut timed = None; - for group in groups { - check_id(group.sequence)?; - if let Some(previous) = prev - && group.sequence <= previous - { - return Err(Error::Sequence); - } - prev = Some(group.sequence); - for frame in &group.frames { - if *timed.get_or_insert(frame.timestamp.is_some()) != frame.timestamp.is_some() { - return Err(Error::Timing); - } - if let Some(timestamp) = frame.timestamp { - check_id(timestamp)?; - } - } - } - Ok(()) -} - -fn write_varint(buf: &mut impl BufMut, value: u64) -> Result<()> { - varint::encode_quic(value, buf).map_err(|_| Error::Overflow) -} - -fn read_varint(buf: &mut impl Buf) -> Result { - varint::decode_quic(buf).map_err(|_| Error::Table) -} - -fn read_count(buf: &mut impl Buf, min_entry: usize) -> Result { - let n = read_varint(buf)?; - let n = usize::try_from(n).map_err(|_| Error::Overflow)?; - if min_entry == 0 || n > buf.remaining() / min_entry { - return Err(Error::Table); - } - Ok(n) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ID_MAX; - - fn frame(timestamp: u64, payload: &'static [u8]) -> Frame { - Frame { - timestamp: Some(timestamp), - payload: Bytes::from_static(payload), - } - } - - fn untimed(payload: &'static [u8]) -> Frame { - Frame { - timestamp: None, - payload: Bytes::from_static(payload), - } - } - - fn object(groups: Vec) -> Object { - Object::new(groups) - } - - fn group(sequence: u64, frames: usize) -> Group { - Group { - sequence, - frames: (0..frames as u64).map(|i| frame(i, b"x")).collect(), - } - } - - #[test] - fn roundtrip_consecutive_and_sparse() { - let original = object(vec![ - Group { - sequence: 0, - frames: vec![frame(0, b"a"), frame(1, b"bb")], - }, - Group { - sequence: 1, - frames: vec![frame(2, b"ccc")], - }, - Group { - sequence: 4, - frames: vec![frame(ID_MAX, b"d")], - }, - ]); - let bytes = original.encode().unwrap(); - assert_eq!(Object::decode(&bytes[..], true).unwrap(), original); - assert_eq!(original.start().unwrap(), Position::group(0)); - - let split = Object { - frame_start: 300, - ..original - }; - assert_eq!(Object::decode(split.encode().unwrap(), true).unwrap(), split); - assert_eq!(split.start().unwrap(), Position::new(0, 300)); - } - - #[test] - fn an_untimed_object_stores_no_timestamps() { - let original = object(vec![ - Group { - sequence: 3, - frames: vec![untimed(b"a"), untimed(b"bb")], - }, - Group { - sequence: 4, - frames: vec![untimed(b"c")], - }, - ]); - let bytes = original.encode().unwrap(); - let mut buf = &bytes[..]; - for expected in [VERSION, 0, 2, 3, 2, 0, 1, 1, 2, 0, 1, 3, 1] { - assert_eq!(read_varint(&mut buf).unwrap(), expected); - } - assert_eq!(buf, b"abbc"); - assert_eq!(Object::decode(bytes.clone(), false).unwrap(), original); - assert!( - Object::decode(bytes, true).is_err(), - "a timed reader misreads the table" - ); - } - - #[test] - fn frames_agree_on_timing() { - let mixed = object(vec![ - Group { - sequence: 0, - frames: vec![frame(0, b"a")], - }, - Group { - sequence: 1, - frames: vec![untimed(b"b")], - }, - ]); - assert_eq!(mixed.encode(), Err(Error::Timing)); - } - - #[test] - fn first_id_is_absolute_then_minus_one_deltas() { - let bytes = object(vec![ - Group { - sequence: 5, - frames: vec![frame(0, b"x")], - }, - Group { - sequence: 6, - frames: vec![frame(1, b"y")], - }, - Group { - sequence: 10, - frames: vec![frame(2, b"z")], - }, - ]) - .encode() - .unwrap(); - - let mut buf = &bytes[..]; - assert_eq!(read_varint(&mut buf).unwrap(), 2); // version - assert_eq!(read_varint(&mut buf).unwrap(), 0); // frame start - assert_eq!(read_varint(&mut buf).unwrap(), 3); // group count - assert_eq!(read_varint(&mut buf).unwrap(), 5); // absolute - assert_eq!(read_varint(&mut buf).unwrap(), 1); // one frame - read_varint(&mut buf).unwrap(); // timestamp - read_varint(&mut buf).unwrap(); // offset - read_varint(&mut buf).unwrap(); // length - assert_eq!(read_varint(&mut buf).unwrap(), 0); // consecutive - assert_eq!(read_varint(&mut buf).unwrap(), 1); - read_varint(&mut buf).unwrap(); - read_varint(&mut buf).unwrap(); - read_varint(&mut buf).unwrap(); - assert_eq!(read_varint(&mut buf).unwrap(), 3); // 10 - 6 - 1 - } - - #[test] - fn empty_object_is_rejected() { - assert!(matches!(object(vec![]).encode(), Err(Error::Empty))); - let mut bytes = BytesMut::new(); - write_varint(&mut bytes, VERSION).unwrap(); - write_varint(&mut bytes, 0).unwrap(); - write_varint(&mut bytes, 0).unwrap(); - assert!(matches!(Object::decode(bytes.freeze(), true), Err(Error::Empty))); - } - - #[test] - fn sequences_must_ascend() { - assert!(matches!( - object(vec![ - Group { - sequence: 2, - frames: vec![frame(0, b"a")], - }, - Group { - sequence: 2, - frames: vec![frame(1, b"b")], - }, - ]) - .encode(), - Err(Error::Sequence) - )); - assert!(matches!( - object(vec![ - Group { - sequence: 2, - frames: vec![frame(0, b"a")], - }, - Group { - sequence: 1, - frames: vec![frame(1, b"b")], - }, - ]) - .encode(), - Err(Error::Sequence) - )); - } - - #[test] - fn reconstructed_ids_must_stay_in_range() { - let mut bytes = BytesMut::new(); - write_varint(&mut bytes, VERSION).unwrap(); - write_varint(&mut bytes, 0).unwrap(); - write_varint(&mut bytes, 2).unwrap(); - write_varint(&mut bytes, ID_MAX).unwrap(); - write_varint(&mut bytes, 0).unwrap(); // no frames - write_varint(&mut bytes, 0).unwrap(); // consecutive => ID_MAX + 1 - write_varint(&mut bytes, 0).unwrap(); - assert!(matches!(Object::decode(bytes.freeze(), true), Err(Error::Id(_)))); - } - - /// A table with one frameless group per delta. - fn deltas(deltas: &[u64]) -> Bytes { - let mut bytes = BytesMut::new(); - write_varint(&mut bytes, VERSION).unwrap(); - write_varint(&mut bytes, 0).unwrap(); - write_varint(&mut bytes, deltas.len() as u64).unwrap(); - for &delta in deltas { - write_varint(&mut bytes, delta).unwrap(); - write_varint(&mut bytes, 0).unwrap(); - } - bytes.freeze() - } - - fn sequences(bytes: Bytes) -> Result> { - Ok(Object::decode(bytes, true)? - .groups - .iter() - .map(|group| group.sequence) - .collect()) - } - - #[test] - fn delta_extremes() { - let varint = (1u64 << 62) - 1; - assert_eq!(sequences(deltas(&[0, 0, 0])).unwrap(), vec![0, 1, 2]); - assert_eq!(sequences(deltas(&[0, 9, 0])).unwrap(), vec![0, 10, 11]); - assert_eq!(sequences(deltas(&[0, ID_MAX - 1])).unwrap(), vec![0, ID_MAX]); - assert_eq!(sequences(deltas(&[ID_MAX])).unwrap(), vec![ID_MAX]); - assert!(matches!(sequences(deltas(&[0, ID_MAX])), Err(Error::Id(_)))); - assert!(matches!(sequences(deltas(&[ID_MAX + 1])), Err(Error::Id(_)))); - assert!(matches!(sequences(deltas(&[varint])), Err(Error::Id(_)))); - assert!(matches!(sequences(deltas(&[ID_MAX, varint])), Err(Error::Id(_)))); - } - - #[test] - fn timestamp_bounds() { - assert!( - object(vec![Group { - sequence: 0, - frames: vec![frame(ID_MAX, b"a")], - }]) - .encode() - .is_ok() - ); - assert!(matches!( - object(vec![Group { - sequence: 0, - frames: vec![frame(ID_MAX + 1, b"a")], - }]) - .encode(), - Err(Error::Id(_)) - )); - } - - #[test] - fn unknown_version_is_refused() { - // Version 1 objects held whole groups under range-named keys. - for version in [1, 3] { - let mut bytes = BytesMut::new(); - write_varint(&mut bytes, version).unwrap(); - write_varint(&mut bytes, 1).unwrap(); - assert!(matches!(Object::decode(bytes.freeze(), true), Err(Error::Version(v)) if v == version)); - } - } - - #[test] - fn payload_must_be_contiguous() { - let mut good = object(vec![Group { - sequence: 0, - frames: vec![frame(0, b"ab")], - }]) - .encode() - .unwrap() - .to_vec(); - good.push(b'x'); - assert!(matches!(Object::decode(Bytes::from(good), true), Err(Error::Table))); - } - - #[test] - fn advertised_counts_must_fit_minimum_entry_size() { - // group_count equals remaining bytes, so a remaining-bytes check would - // accept it, but each group needs at least two varints. - let mut bytes = BytesMut::new(); - write_varint(&mut bytes, VERSION).unwrap(); - write_varint(&mut bytes, 0).unwrap(); - write_varint(&mut bytes, 4).unwrap(); - bytes.extend_from_slice(&[0, 0, 0, 0]); - assert!(matches!(Object::decode(bytes.freeze(), true), Err(Error::Table))); - } - - #[test] - fn offset_overflow_and_truncated_table() { - let mut bytes = BytesMut::new(); - for value in [VERSION, 0, 1, 0, 1, 0, 0, 4] { - write_varint(&mut bytes, value).unwrap(); // length 4, no payload - } - assert!(matches!(Object::decode(bytes.freeze(), true), Err(Error::Table))); - - assert!(matches!(Object::decode(&b"\x02"[..], true), Err(Error::Table))); - } - - #[test] - fn frame_offsets_must_tile_the_payload() { - // One group of two frames at (offset, length), followed by `payload` bytes. - let table = |frames: [(u64, u64); 2], payload: usize| { - let mut bytes = BytesMut::new(); - for value in [VERSION, 0, 1, 0, 2] { - write_varint(&mut bytes, value).unwrap(); - } - for (offset, length) in frames { - for value in [0, offset, length] { - write_varint(&mut bytes, value).unwrap(); - } - } - bytes.extend_from_slice(&vec![b'x'; payload]); - Object::decode(bytes.freeze(), true) - }; - assert!(table([(0, 1), (1, 2)], 3).is_ok()); - assert!(matches!(table([(0, 1), (0, 2)], 3), Err(Error::Table)), "overlap"); - assert!(matches!(table([(0, 1), (2, 1)], 3), Err(Error::Table)), "gap"); - assert!(matches!(table([(1, 1), (2, 1)], 3), Err(Error::Table)), "late start"); - assert!(matches!(table([(0, 1), (1, 3)], 3), Err(Error::Table)), "past the end"); - let varint = (1u64 << 62) - 1; - assert!( - matches!(table([(0, 1), (1, varint)], 3), Err(Error::Table)), - "huge length" - ); - } - - #[test] - fn a_span_must_match_the_record() { - let whole = object(vec![group(5, 2), group(6, 1)]); - whole.check_span(Position::group(5), Position::group(7)).unwrap(); - assert_eq!( - whole.check_span(Position::group(5), Position::group(8)), - Err(Error::Span) - ); - assert_eq!( - whole.check_span(Position::group(4), Position::group(7)), - Err(Error::Span) - ); - assert_eq!( - whole.check_span(Position::group(5), Position::new(6, 2)), - Err(Error::Span), - "a partial end names its frame count" - ); - - // A frame split: frames 3..5 of group 7. - let split = Object { - frame_start: 3, - groups: vec![group(7, 2)], - }; - split.check_span(Position::new(7, 3), Position::new(7, 5)).unwrap(); - assert_eq!( - split.check_span(Position::new(7, 3), Position::new(7, 4)), - Err(Error::Span) - ); - - // A split's tail continues into whole groups. - let tail = Object { - frame_start: 3, - groups: vec![group(7, 2), group(8, 4)], - }; - tail.check_span(Position::new(7, 3), Position::new(8, 4)).unwrap(); - tail.check_span(Position::new(7, 3), Position::group(9)).unwrap(); - - // Gaps and empty groups never appear inside a record. - let gap = object(vec![group(5, 1), group(7, 1)]); - assert_eq!(gap.check_span(Position::group(5), Position::group(8)), Err(Error::Span)); - let empty = object(vec![group(5, 1), group(6, 0)]); - assert_eq!( - empty.check_span(Position::group(5), Position::group(7)), - Err(Error::Span) - ); - } - - #[test] - fn endpoints_roundtrip() { - let original = object(vec![Group { - sequence: ID_MAX, - frames: vec![frame(0, b"")], - }]); - assert_eq!(Object::decode(original.encode().unwrap(), true).unwrap(), original); - } -} diff --git a/rs/moq-archive/src/store.rs b/rs/moq-archive/src/store.rs index d3d7cee6809..6968ce49b05 100644 --- a/rs/moq-archive/src/store.rs +++ b/rs/moq-archive/src/store.rs @@ -1,120 +1,12 @@ -use std::num::NonZeroUsize; - use bytes::Bytes; -use futures::StreamExt; -use futures::stream::BoxStream; -use object_store::list::{PaginatedListOptions, PaginatedListResult, PaginatedListStore}; use object_store::path::Path; -use object_store::{ListResult, ObjectMeta, ObjectStore, ObjectStoreExt, PutMode, PutPayload}; +use object_store::{ObjectStore, ObjectStoreExt, PutMode, PutPayload}; use crate::info::Info; -use crate::path::Key; -use crate::segment::Object; +use crate::layout; use crate::{Error, Result}; -/// Recording object listing types. -/// -/// Listing scopes and offsets are relative to the recording prefix supplied to -/// [`super::Store::new`]. The same [`Query`] can drive streaming and paginated -/// listing. A page's [`Page::next`] query retains the opaque backend token and -/// must be passed to [`super::Store::list_paginated`], not [`super::Store::list`]. -/// -/// ``` -/// use std::num::NonZeroUsize; -/// -/// use moq_archive::store::list::Query; -/// -/// let query = Query::segments("video.timeline.z")? -/// .page_size(NonZeroUsize::new(100).unwrap()); -/// # Ok::<(), moq_archive::Error>(()) -/// ``` -pub mod list { - use std::num::NonZeroUsize; - - use object_store::path::Path; - - use crate::path; - use crate::{Key, Result}; - - /// A recording-relative object listing query. - #[derive(Debug, Clone, Default, PartialEq, Eq)] - pub struct Query { - pub(crate) prefix: Option, - pub(crate) offset: Option, - pub(crate) max_keys: Option, - pub(crate) page_token: Option, - } - - impl Query { - /// List every object in the recording. - pub fn new() -> Self { - Self::default() - } - - fn prefix(mut self, prefix: impl Into) -> Self { - self.prefix = Some(prefix.into()); - self.page_token = None; - self - } - - fn offset(mut self, offset: impl Into) -> Self { - self.offset = Some(offset.into()); - self.page_token = None; - self - } - - /// Request at most this many entries per page. - pub fn page_size(mut self, max_keys: NonZeroUsize) -> Self { - self.max_keys = Some(max_keys); - self.page_token = None; - self - } - - /// List every object for one track. - pub fn track(track: &str) -> Result { - Ok(Self::new().prefix(path::track_prefix(track)?)) - } - - /// List segment objects for one track. - pub fn segments(track: &str) -> Result { - Ok(Self::new().prefix(path::segments_prefix(&Path::ROOT, track)?)) - } - - /// Start strictly after this recording object. - pub fn after(self, key: &Key) -> Result { - Ok(self.offset(key.path(&Path::ROOT)?)) - } - - pub(crate) fn next(&self, page_token: String) -> Self { - let mut next = self.clone(); - next.page_token = Some(page_token); - next - } - } - - /// One recording object returned by a listing. - #[derive(Debug, Clone, PartialEq, Eq)] - pub struct Entry { - /// Parsed recording key. - pub key: Key, - /// Object size in bytes. - pub size: u64, - } - - /// One page of recording objects. - #[derive(Debug, Clone)] - pub struct Page { - /// Entries in the backend's order. - pub entries: Vec, - /// The same query advanced to the next page, if any. - /// Pass it to `Store::list_paginated`; `Store::list` rejects it. - pub next: Option, - } -} - -use list::{Entry, Page, Query}; - -/// Versioned recording objects on a generic [`ObjectStore`]. +/// A broadcast's archive: an [`ObjectStore`] and the key prefix it is stored under. #[derive(Clone, Debug)] pub struct Store { inner: T, @@ -140,263 +32,60 @@ impl Store { &self.prefix } - /// Encode `key` under this store's prefix. - pub fn path(&self, key: &Key) -> Result { - key.path(&self.prefix) - } - - /// Create `.info`, or accept an existing object with the same parsed properties. - pub async fn put_info(&self, track: &str, info: &Info) -> Result { - let key = Key::info(track)?; - let path = self.path(&key)?; + /// Create a track's `.info`, or accept an existing object with the same parsed properties. + pub async fn put_info(&self, track: &str, info: &Info) -> Result<()> { + let path = self.info_path(track)?; let bytes = info.encode()?; - match self.create(&path, bytes).await? { - Create::Created => Ok(key), - Create::Exists => { - let existing = self.get_bytes(&path).await?; - let parsed = Info::decode(&existing)?; - if parsed.priority != info.priority { + match self + .inner + .put_opts(&path, PutPayload::from(bytes), PutMode::Create.into()) + .await + { + Ok(_) => Ok(()), + Err(object_store::Error::AlreadyExists { .. }) => { + let existing = Info::decode(&self.get_bytes(&path).await?)?; + if existing.priority != info.priority { return Err(Error::Priority { - existing: parsed.priority, + existing: existing.priority, intended: info.priority, }); } - if parsed.timescale != info.timescale { + if existing.timescale != info.timescale { return Err(Error::TimescaleMismatch { - existing: parsed.timescale, + existing: existing.timescale, intended: info.timescale, }); } - Ok(key) + Ok(()) } + Err(err) => Err(err.into()), } } /// Fetch and validate a track's `.info`. pub async fn get_info(&self, track: &str) -> Result { - let path = self.path(&Key::info(track)?)?; - Info::decode(&self.get_bytes(&path).await?) - } - - /// Create the object at `segments/`. A collision is accepted only when the bytes match. - pub async fn put_segments(&self, track: &str, segment: u64, object: &Object) -> Result { - let key = Key::segments(track, segment)?; - self.put_segment(&key, object.encode()?).await?; - Ok(key) - } - - /// Fetch and decode the object at `segments/`, whose frames store timestamps when - /// `timed`, as the track's `.info` says. - pub async fn get_segments(&self, track: &str, segment: u64, timed: bool) -> Result { - let path = self.path(&Key::segments(track, segment)?)?; - Object::decode(self.get_bytes(&path).await?, timed) - } - - /// Each recorded track's timeline track, found by listing the recording's track directories: - /// a timeline is named by [`hang::timeline::default_name`]. One listing of the - /// prefix's directories plus a HEAD per timeline, so following a long recording doesn't list - /// its objects. A timeline whose `.info` isn't stored yet, such as one still enrolling, is left - /// out until it is. - /// - /// For replaying a recording without its catalog; a catalog's `archive` entry names the same map. - pub async fn timelines(&self) -> Result> { - let listed = self.inner.list_with_delimiter(self.list_path(None).as_ref()).await?; - let mut timelines = std::collections::BTreeMap::new(); - for directory in listed.common_prefixes { - let encoded = directory - .filename() - .ok_or_else(|| Error::Directory(directory.to_string()))?; - let track = crate::path::decode_track(encoded)?; - let Some(indexed) = track.strip_suffix(hang::timeline::SUFFIX) else { - continue; - }; - match self.inner.head(&self.path(&Key::info(&track)?)?).await { - Ok(_) => timelines.insert(indexed.to_string(), track), - Err(object_store::Error::NotFound { .. }) => continue, - Err(err) => return Err(err.into()), - }; - } - Ok(timelines) - } - - /// Delete the object at `key`. - pub async fn delete(&self, key: &Key) -> Result<()> { - let path = self.path(key)?; - self.inner.delete(&path).await?; - Ok(()) + Info::decode(&self.get_bytes(&self.info_path(track)?).await?) } - /// Stream matching entries. Order is unspecified and `max_keys` is ignored. - /// Continuation queries from `Page::next` fail; pass them to `list_paginated`. - pub fn list(&self, query: &Query) -> BoxStream<'static, Result> { - if query.page_token.is_some() { - return futures::stream::once(async { Err(Error::Pagination) }).boxed(); - } - let prefix = self.list_path(query.prefix.as_ref()); - let offset = query.offset.as_ref().map(|offset| self.recording_path(offset)); - let store_prefix = self.prefix.clone(); - let stream = match offset.as_ref() { - Some(offset) => self.inner.list_with_offset(prefix.as_ref(), offset), - None => self.inner.list(prefix.as_ref()), - }; - stream.map(move |item| Entry::from_meta(&store_prefix, item?)).boxed() - } - - async fn put_segment(&self, key: &Key, bytes: Bytes) -> Result<()> { - let path = self.path(key)?; - match self.create(&path, bytes.clone()).await? { - Create::Created => Ok(()), - Create::Exists => { - let existing = self.get_bytes(&path).await?; - if existing == bytes { - Ok(()) - } else { - Err(Error::Conflict(path.to_string())) - } - } - } - } - - async fn create(&self, path: &Path, bytes: Bytes) -> Result { - match self - .inner - .put_opts(path, PutPayload::from(bytes), PutMode::Create.into()) - .await - { - Ok(_) => Ok(Create::Created), - Err(object_store::Error::AlreadyExists { .. }) => Ok(Create::Exists), - Err(err) => Err(err.into()), - } + fn info_path(&self, track: &str) -> Result { + crate::path::push(&layout::track_dir(&self.prefix, track)?, layout::TRACK_INFO) } async fn get_bytes(&self, path: &Path) -> Result { Ok(self.inner.get(path).await?.bytes().await?) } - - fn recording_path(&self, relative: &Path) -> Path { - let mut path = self.prefix.clone(); - path.extend(relative.parts()); - path - } - - fn list_path(&self, relative: Option<&Path>) -> Option { - let path = relative.map_or_else(|| self.prefix.clone(), |relative| self.recording_path(relative)); - (!path.as_ref().is_empty()).then_some(path) - } - - fn paginated_prefix(&self, relative: Option<&Path>) -> Option { - self.list_path(relative).map(|path| format!("{path}/")) - } - - fn paginated_options(&self, query: &Query) -> PaginatedListOptions { - PaginatedListOptions { - offset: query - .offset - .as_ref() - .map(|offset| self.recording_path(offset).to_string()), - max_keys: query.max_keys.map(NonZeroUsize::get), - page_token: query.page_token.clone(), - ..Default::default() - } - } - - fn page(&self, query: &Query, result: PaginatedListResult) -> Result { - let PaginatedListResult { result, page_token } = result; - let entries = self.entries(result)?; - let next = page_token.map(|token| query.next(token)); - Ok(Page { entries, next }) - } - - fn entries(&self, result: ListResult) -> Result> { - if let Some(prefix) = result.common_prefixes.first() { - return Err(Error::Directory(prefix.to_string())); - } - result - .objects - .into_iter() - .map(|meta| Entry::from_meta(&self.prefix, meta)) - .collect() - } -} - -impl Store { - /// List one page while keeping the backend continuation token inside the returned query. - pub async fn list_paginated(&self, query: &Query) -> Result { - let prefix = self.paginated_prefix(query.prefix.as_ref()); - let opts = self.paginated_options(query); - let result = self.inner.list_paginated(prefix.as_deref(), opts).await?; - self.page(query, result) - } -} - -impl Entry { - fn from_meta(prefix: &Path, meta: ObjectMeta) -> Result { - let key = Key::parse(prefix, &meta.location)?; - Ok(Self { key, size: meta.size }) - } -} - -enum Create { - Created, - Exists, } #[cfg(test)] mod tests { - use std::collections::BTreeSet; - use std::num::NonZeroUsize; - - use futures::TryStreamExt; - use object_store::ObjectStoreExt; use object_store::memory::InMemory; - use object_store::path::Path; use super::*; - use crate::ID_MAX; - use crate::mock::Mock; - use crate::path::encode_track; - use crate::segment::{Frame, Group}; fn memory() -> Store { Store::new(InMemory::new(), "rec") } - fn frame(timestamp: u64, payload: &'static [u8]) -> Frame { - Frame { - timestamp: Some(timestamp), - payload: Bytes::from_static(payload), - } - } - - fn one_group(sequence: u64, payload: &'static [u8]) -> Object { - Object::new(vec![Group { - sequence, - frames: vec![frame(sequence, payload)], - }]) - } - - fn two_groups() -> Object { - Object::new(vec![ - Group { - sequence: 5, - frames: vec![frame(0, b"a")], - }, - Group { - sequence: 6, - frames: vec![frame(1, b"bb")], - }, - ]) - } - - async fn names(store: &Store, query: &Query) -> BTreeSet { - store - .list(query) - .map_ok(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()) - .try_collect() - .await - .unwrap() - } - #[tokio::test] async fn info_create_get_and_idempotent_retry() { let store = memory(); @@ -404,7 +93,7 @@ mod tests { store.put_info("catalog.json", &info).await.unwrap(); assert_eq!(store.get_info("catalog.json").await.unwrap(), info); - let path = store.path(&Key::info("catalog.json").unwrap()).unwrap(); + let path = Path::from("rec/catalog%2Ejson/.info"); store .inner() .put( @@ -438,7 +127,7 @@ mod tests { matches!(err, Error::Timescale(0)) }), ] { - let path = store.path(&Key::info(track).unwrap()).unwrap(); + let path = Path::from(format!("rec/{track}/.info")); store.inner().put(&path, existing.to_vec().into()).await.unwrap(); let err = store.put_info(track, &info).await.unwrap_err(); assert!(check(&err), "{track}: {err}"); @@ -473,406 +162,4 @@ mod tests { Info::new(0, Some(1_000)).unwrap() ); } - - #[tokio::test] - async fn segments_put_get_and_identical_collision() { - let store = memory(); - let object = two_groups(); - let key = store.put_segments("video", 3, &object).await.unwrap(); - assert_eq!(key, Key::segments("video", 3).unwrap()); - assert_eq!( - store.path(&key).unwrap().as_ref(), - "rec/video/segments/0000000000000000003" - ); - assert_eq!(store.get_segments("video", 3, true).await.unwrap(), object); - store.put_segments("video", 3, &object).await.unwrap(); - - let other = Object::new(vec![Group { - sequence: 5, - frames: vec![frame(0, b"X")], - }]); - assert!(matches!( - store.put_segments("video", 3, &other).await, - Err(Error::Conflict(_)) - )); - } - - #[tokio::test] - async fn segments_put_get_and_delete() { - let store = memory(); - let object = one_group(0, b"tl"); - store.put_segments("timeline.z", 0, &object).await.unwrap(); - assert_eq!(store.get_segments("timeline.z", 0, true).await.unwrap(), object); - store.delete(&Key::segments("timeline.z", 0).unwrap()).await.unwrap(); - assert!(matches!( - store.get_segments("timeline.z", 0, true).await, - Err(Error::NotFound(_)) - )); - } - - #[tokio::test] - async fn listing_names_every_object() { - let store = memory(); - store.put_info("video", &Info::new(0, Some(1)).unwrap()).await.unwrap(); - store.put_segments("video", 1, &one_group(1, b"a")).await.unwrap(); - store.put_segments("video", 3, &one_group(3, b"b")).await.unwrap(); - store.put_segments("timeline.z", 2, &one_group(0, b"t")).await.unwrap(); - - let listed: Vec<_> = store.list(&Query::new()).try_collect().await.unwrap(); - let mut keys: Vec<_> = listed.into_iter().map(|listed| listed.key).collect(); - keys.sort_by(|a, b| store.path(a).unwrap().as_ref().cmp(store.path(b).unwrap().as_ref())); - assert_eq!( - keys, - vec![ - Key::segments("timeline.z", 2).unwrap(), - Key::info("video").unwrap(), - Key::segments("video", 1).unwrap(), - Key::segments("video", 3).unwrap(), - ] - ); - } - - #[tokio::test] - async fn list_with_offset_finishes_before_the_caller_sorts() -> Result<()> { - let store = memory(); - for segment in [0u64, 2, 1] { - store - .put_segments("timeline.z", segment, &one_group(segment, b"t")) - .await - .unwrap(); - } - let query = Query::segments("timeline.z")?.after(&Key::segments("timeline.z", 0)?)?; - let mut rest: Vec<_> = store - .list(&query) - .map_ok(|entry| match entry.key { - Key::Segments { segment, .. } => segment, - _ => panic!("expected a segment key"), - }) - .try_collect() - .await?; - rest.sort(); - assert_eq!(rest, vec![1, 2]); - Ok(()) - } - - #[tokio::test] - async fn percent_encoded_track_listing() { - let store = memory(); - store - .put_info("catalog.json", &Info::new(0, Some(1)).unwrap()) - .await - .unwrap(); - let locations = names(&store, &Query::new()).await; - assert!(locations.contains(&format!("{}/.info", encode_track("catalog.json").unwrap()))); - } - - #[tokio::test] - async fn listing_query_is_recording_relative() { - let store = memory(); - store.put_info("video", &Info::new(0, Some(1)).unwrap()).await.unwrap(); - store.put_segments("video", 1, &one_group(1, b"a")).await.unwrap(); - store - .put_info("video-alt", &Info::new(0, Some(1)).unwrap()) - .await - .unwrap(); - - let query = Query::track("video").unwrap(); - let paths = names(&store, &query).await; - assert_eq!( - paths, - BTreeSet::from([ - "video/.info".to_string(), - "video/segments/0000000000000000001".to_string(), - ]) - ); - assert_eq!( - store.paginated_prefix(query.prefix.as_ref()).as_deref(), - Some("rec/video/") - ); - } - - #[tokio::test] - async fn paginated_recording_prefix_excludes_siblings() { - let store = memory(); - store.put_info("video", &Info::new(0, Some(1)).unwrap()).await.unwrap(); - store - .inner() - .put( - &Path::from("rec-other/video/.info"), - Bytes::from_static(b"sibling").into(), - ) - .await - .unwrap(); - - let prefix = store.paginated_prefix(None).unwrap(); - assert_eq!(prefix, "rec/"); - let objects = store - .inner() - .list(None) - .try_filter(|meta| futures::future::ready(meta.location.as_ref().starts_with(&prefix))) - .try_collect() - .await - .unwrap(); - let entries = store - .entries(ListResult { - objects, - common_prefixes: Vec::new(), - extensions: Default::default(), - }) - .unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].key, Key::info("video").unwrap()); - } - - #[tokio::test] - async fn paginated_query_preserves_every_supported_option() { - let store = memory(); - let query = Query::segments("timeline.z") - .unwrap() - .after(&Key::segments("timeline.z", 1).unwrap()) - .unwrap() - .page_size(NonZeroUsize::new(2).unwrap()); - let options = store.paginated_options(&query); - assert_eq!( - store.paginated_prefix(query.prefix.as_ref()).as_deref(), - Some("rec/timeline%2Ez/segments/") - ); - assert_eq!( - options.offset.as_deref(), - Some("rec/timeline%2Ez/segments/0000000000000000001") - ); - assert_eq!(options.max_keys, Some(2)); - assert_eq!(options.page_token, None); - - let page = store - .page( - &query, - PaginatedListResult { - result: ListResult { - objects: Vec::new(), - common_prefixes: Vec::new(), - extensions: Default::default(), - }, - page_token: Some("opaque".to_string()), - }, - ) - .unwrap(); - let next = page.next.unwrap(); - assert_eq!(next.prefix, query.prefix); - assert_eq!(next.offset, query.offset); - assert_eq!(next.max_keys, query.max_keys); - assert_eq!(store.paginated_options(&next).page_token.as_deref(), Some("opaque")); - - let changed = next.page_size(NonZeroUsize::new(3).unwrap()); - assert_eq!(store.paginated_options(&changed).page_token, None); - } - - #[tokio::test] - async fn paginated_pages_match_streaming_results() { - let store = memory(); - for segment in 0..3 { - store - .put_segments("timeline.z", segment, &one_group(segment, b"t")) - .await - .unwrap(); - } - let query = Query::segments("timeline.z") - .unwrap() - .after(&Key::segments("timeline.z", 0).unwrap()) - .unwrap() - .page_size(NonZeroUsize::new(1).unwrap()); - let streamed = names(&store, &query).await; - let mut metas: Vec<_> = store - .inner() - .list(store.list_path(query.prefix.as_ref()).as_ref()) - .try_collect() - .await - .unwrap(); - metas.sort_by(|a, b| a.location.cmp(&b.location)); - metas.retain(|meta| meta.location > store.recording_path(query.offset.as_ref().unwrap())); - - let mut paged = BTreeSet::new(); - let mut current = query; - for (index, meta) in metas.iter().cloned().enumerate() { - let page = store - .page( - ¤t, - PaginatedListResult { - result: ListResult { - objects: vec![meta], - common_prefixes: Vec::new(), - extensions: Default::default(), - }, - page_token: (index + 1 < metas.len()).then(|| format!("page-{index}")), - }, - ) - .unwrap(); - paged.extend( - page.entries - .into_iter() - .map(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()), - ); - if let Some(next) = page.next { - current = next; - } - } - assert_eq!(paged, streamed); - } - - #[tokio::test] - async fn streaming_rejects_continuation_queries() { - let store = memory(); - store.put_segments("timeline.z", 0, &one_group(0, b"t")).await.unwrap(); - let query = Query::segments("timeline.z") - .unwrap() - .page_size(NonZeroUsize::new(1).unwrap()); - let next = query.next("0".to_string()); - let err = store.list(&next).try_collect::>().await.unwrap_err(); - assert_eq!(err, Error::Pagination); - } - - #[tokio::test] - async fn paginated_listing_walks_pages_and_excludes_siblings() { - let store = Store::new(Mock::memory(), "rec"); - for segment in 0..3 { - store - .put_segments("timeline.z", segment, &one_group(segment, b"t")) - .await - .unwrap(); - } - store - .inner() - .put( - &Path::from("rec-other/video/.info"), - Bytes::from_static(b"sibling").into(), - ) - .await - .unwrap(); - - let query = Query::segments("timeline.z") - .unwrap() - .after(&Key::segments("timeline.z", 0).unwrap()) - .unwrap() - .page_size(NonZeroUsize::new(1).unwrap()); - let streamed: BTreeSet = store - .list(&query) - .map_ok(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()) - .try_collect() - .await - .unwrap(); - assert_eq!(streamed.len(), 2); - - let mut paged = BTreeSet::new(); - let mut current = Some(query); - let mut pages = 0; - while let Some(query) = current { - let page = store.list_paginated(&query).await.unwrap(); - assert_eq!(page.entries.len(), 1); - paged.extend( - page.entries - .into_iter() - .map(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()), - ); - current = page.next; - pages += 1; - assert!(pages <= 2, "pagination did not terminate"); - } - assert_eq!(pages, 2); - assert_eq!(paged, streamed); - - // A recording-wide walk uses the trailing-slash scope, so the - // `rec-other` sibling must not appear in any page. - let query = Query::new().page_size(NonZeroUsize::new(1).unwrap()); - let streamed: BTreeSet = store - .list(&query) - .map_ok(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()) - .try_collect() - .await - .unwrap(); - assert_eq!(streamed.len(), 3); - - let mut paged = BTreeSet::new(); - let mut current = Some(query); - let mut pages = 0; - while let Some(query) = current { - let page = store.list_paginated(&query).await.unwrap(); - assert_eq!(page.entries.len(), 1); - paged.extend( - page.entries - .into_iter() - .map(|entry| entry.key.path(&Path::ROOT).unwrap().to_string()), - ); - current = page.next; - pages += 1; - assert!(pages <= 3, "pagination did not terminate"); - } - assert_eq!(pages, 3); - assert_eq!(paged, streamed); - } - - #[tokio::test] - async fn empty_prefix_lists_the_whole_store() { - let store = Store::new(Mock::memory(), ""); - store - .put_info("catalog.json", &Info::new(0, Some(1)).unwrap()) - .await - .unwrap(); - store.put_segments("video", 4, &one_group(4, b"a")).await.unwrap(); - assert_eq!(store.paginated_prefix(None), None); - - let expected = - std::collections::HashSet::from([Key::info("catalog.json").unwrap(), Key::segments("video", 4).unwrap()]); - let streamed: std::collections::HashSet = store - .list(&Query::new()) - .map_ok(|entry| entry.key) - .try_collect() - .await - .unwrap(); - assert_eq!(streamed, expected); - let page = store.list_paginated(&Query::new()).await.unwrap(); - assert_eq!( - page.entries - .into_iter() - .map(|entry| entry.key) - .collect::>(), - expected - ); - assert!(page.next.is_none()); - let page = store.list_paginated(&Query::segments("video").unwrap()).await.unwrap(); - assert_eq!(page.entries.len(), 1); - } - - #[test] - fn directory_results_are_not_silently_dropped() { - let store = memory(); - let err = store - .entries(ListResult { - objects: Vec::new(), - common_prefixes: vec![Path::from("rec/video")], - extensions: Default::default(), - }) - .unwrap_err(); - assert_eq!(err, Error::Directory("rec/video".to_string())); - } - - #[tokio::test] - async fn id_endpoints_are_valid_keys() { - let store = memory(); - let object = one_group(ID_MAX, b"z"); - store.put_segments("t", ID_MAX, &object).await.unwrap(); - assert_eq!(store.get_segments("t", ID_MAX, true).await.unwrap(), object); - } - - #[tokio::test] - async fn local_disk_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let inner = object_store::local::LocalFileSystem::new_with_prefix(dir.path()).unwrap(); - let store = Store::new(inner, Path::from("rec")); - let info = Info::new(3, Some(90_000)).unwrap(); - store.put_info("audio", &info).await.unwrap(); - let object = two_groups(); - store.put_segments("audio", 0, &object).await.unwrap(); - assert_eq!(store.get_info("audio").await.unwrap(), info); - assert_eq!(store.get_segments("audio", 0, true).await.unwrap(), object); - } } From df2d4d65c80ec2b69c5f5d8f72940b2d7b568060 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 22:15:33 -0700 Subject: [PATCH 21/48] feat(cli,hls): import archive and HLS tests serve through archive::Consumer Co-Authored-By: Claude Opus 5.5 --- doc/bin/cli.md | 17 +- doc/bin/hls.md | 23 +- quest/m1/archive/README.md | 1 - quest/m1/archive/catalog-hls.md | 4 - quest/m1/archive/consumer.md | 84 --- quest/m1/archive/history-floor.md | 3 +- quest/m2/archive-dvr.md | 2 +- quest/m2/archive-time-ranges.md | 30 + quest/m3/archive-paced-replay.md | 6 +- rs/moq-archive/MIGRATE.md | 10 + rs/moq-archive/README.md | 12 +- rs/moq-archive/src/consumer/index.rs | 4 +- rs/moq-archive/src/consumer/mod.rs | 4 +- rs/moq-archive/src/consumer/tests.rs | 68 +- rs/moq-archive/src/consumer/track.rs | 10 +- rs/moq-cli/MIGRATE.md | 7 +- rs/moq-cli/src/archive.rs | 115 ++-- rs/moq-hls/src/export/archive_tests.rs | 818 +++++++++++-------------- rs/moq-hls/src/export/mod.rs | 2 +- 19 files changed, 600 insertions(+), 620 deletions(-) delete mode 100644 quest/m1/archive/consumer.md create mode 100644 quest/m2/archive-time-ranges.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 6c6ce75edec..0c7259122b9 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -303,8 +303,8 @@ equivalents. # Record every broadcast announced under `event` moq --connect https://relay.example.com/anon --broadcast event export archive s3://recordings -# Replay a timeline recording under another name -moq --connect https://relay.example.com/anon --broadcast event-replay.hang import archive s3://recordings/event +# Replay one recorded broadcast under another name +moq --connect https://relay.example.com/anon --broadcast event-replay.hang import archive s3://recordings/event/@ ``` `export archive` records every broadcast announced at or under `--broadcast` @@ -321,10 +321,15 @@ or stitches into an older recording. Any recording that fails ends the export. `--retention 1h` keeps only the last hour, a DVR, deleting older objects `--retention-grace` (default 30 s) after they leave the window. -`import archive` republishes a timeline recording made by an older `moq` as -live tracks, fetching spans on request. It ends where the recording ends; -`--follow 2s` keeps polling a recording still being made. It does not yet read -the per-track layout that `export archive` writes. +`import archive` republishes one recorded broadcast, the `/@` +prefix `export archive` wrote, as an ordinary broadcast under a new epoch: every +track, the catalog included, with its original groups, frames, timescale, and +priority. Each track is live from its newest stored group and serves older +groups from the store on request, with no max age, so the whole recording stays +reachable. A recording still being made is followed, checked every `--refresh` +(default 1 s), until `export archive` marks it complete; one whose export +stopped early stays live. It refuses a prefix with no recording, or with keys +`export archive` does not write. Store URLs are `file:///absolute/path`, `s3://bucket/prefix`, `gs://bucket/prefix`, or `az://container/prefix`, with credentials from the diff --git a/doc/bin/hls.md b/doc/bin/hls.md index 33ac9a7e15a..e9cf54aec6e 100644 --- a/doc/bin/hls.md +++ b/doc/bin/hls.md @@ -38,13 +38,22 @@ What that means for you: - **Segment URLs are stable.** Every edge and every reload derive the same URLs, so a CDN in front of export caches them. - **Only a clean finish ends a playlist.** `EXT-X-ENDLIST` and a `static` DASH manifest mean the timeline finished. If the segment-boundary rendition's timeline fails instead, such as a publisher dropping its session, every media playlist and the DASH manifest answer HTTP 500 with `Cache-Control: no-store`, so a truncated recording never looks complete. A rendition whose own timeline fails answers the error alone and leaves the multivariant playlist and DASH manifest, so players switch to a sibling. -A [`moq-archive`](https://docs.rs/moq-archive) recording replayed through its -`Reader` is served the same way, with no second stored copy, and lists the -same window as a live broadcast by default. `export::Config::history` includes -earlier timeline groups even after replay-cache eviction: the exporter FETCHes -missing groups from the Reader's stored timeline objects before following live -edits. Missing or unreadable stored history fails that export with an error, -and so does a recording whose start was pruned by retention. +A [`moq-archive`](https://docs.rs/moq-archive) recording is served the same +way, with no second stored copy: record a broadcast, republish it, and export +the republished broadcast. + +```bash +moq --connect https://relay.example.com/anon --broadcast event export archive s3://recordings +moq --connect https://relay.example.com/anon --broadcast event-replay.hang import archive s3://recordings/event/@ +moq --connect https://relay.example.com/anon --broadcast event-replay.hang export hls --listen '[::]:8089' +``` + +The archived catalog and timelines replay as recorded, so the export lists the +same window as a live broadcast by default. When the recorded catalog's +`archive` entry names a `store`, `export::Config::history` lists the whole +recording, FETCHing earlier timeline groups from the stored objects. Missing or +unreadable stored history fails that export with an error, and so does a +recording whose start was pruned by retention. With `export::Config::history` enabled, a durable recording's media playlists also accept `start` and `end` query parameters in decimal media-time seconds. diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index d528cdb82c3..a0f7d6a500d 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -85,7 +85,6 @@ from a host. Each child fixes the stale docs it touches. ## Required - [TRACKS in js/net](/quest/m1/archive/tracks-js.md) - `@moq/net` lists a broadcast's tracks over lite-07's Tracks Stream and answers listings of its own broadcasts, mirroring Rust -- [Archive consumer](/quest/m1/archive/consumer.md) - `archive::Consumer` serves a stored prefix as a broadcast from its key listing, and `moq export/import archive` use the new pair - [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the root `archive` section is gone; an opt-in per-rendition `hls` field marks each aligned HLS segment's first group and pts and declares its nominal duration - [SUBSCRIBE_TRACKS](/quest/m1/archive/subscribe-tracks.md) - moq-transport SUBSCRIBE_TRACKS is answered through the TRACKS hook, and an IETF peer's tracks are learned with FORWARD=0, instead of NOT_SUPPORTED - [Fixed HLS target duration](/quest/m1/archive/hls-target.md) - one `EXT-X-TARGETDURATION` for the run, from the declared `hls` duration; an overrun is listed with a warning diff --git a/quest/m1/archive/catalog-hls.md b/quest/m1/archive/catalog-hls.md index 00059cc7fa6..d6655e20d75 100644 --- a/quest/m1/archive/catalog-hls.md +++ b/quest/m1/archive/catalog-hls.md @@ -66,7 +66,3 @@ segment is refused, and that a rendition without `hls` gets no playlist. Public API: `hang::catalog::Archive` is deleted, the rendition types gain an `hls` field in Rust and JS, and `moq-mux` gains the opt-in. Wire: the hang catalog format. - -## Required - -- [Archive consumer](/quest/m1/archive/consumer.md) - the old writer and reader, which read the root `archive` section, are gone first diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md deleted file mode 100644 index 74ea2e92e8d..00000000000 --- a/quest/m1/archive/consumer.md +++ /dev/null @@ -1,84 +0,0 @@ -# [M] Archive consumer - -## Goal - -`archive::Consumer` serves one broadcast's stored key prefix as an ordinary -broadcast: every stored track, the archived catalog included, with its -original groups, frames, timescale, and priority. A host publishes it at a path of its choosing; -moq-relay never uses it. `moq export archive` already records through -`archive::Producer`; `moq import archive` serves through -`archive::Consumer`. It replaces `moq_archive::Reader`, and with it goes -`.info`'s tolerance of the `epoch` an older `Writer` stored. - -## Plan - -Decided by the maintainer on 2026-10-17 (see the -[epic](/quest/m1/archive/README.md)): - -- One cached LIST of the prefix is the index: it names the tracks and maps - any group or pts to an object. Each track's `.info` supplies its timescale - and priority before any media is read. A growing archive refreshes each - track with its own cursor, a LIST of that track's key prefix StartAfter its - newest known key, since one prefix-wide cursor skips a new key under a - lexically earlier track. A delimiter LIST of the broadcast prefix finds - tracks added since. Every object is written once, an open group continuing - in the next object (decided 2026-10-21). -- Each served track advertises no Publisher Max Age limit (TRACK_INFO's - encoded `0`), not the source's: the store's retention bounds what it holds, - and DVR pruning is the publisher dropping a group early, which TRACK_INFO - permits. Advertising the source's bound would expire every archived group - older than it relative to the newest. -- Serve through `broadcast::Dynamic` and `track::Dynamic`. Create every listed - track up front, so TRACKS on the served broadcast lists them all. -- The archived catalog is an ordinary track, so a reader sees its newest - stored group, not a union of every rendition recorded. A rendition removed - before the archive ends is therefore not listed at the end; the replay-catalog - quest's union and `enabled: false` handling are dropped with it. -- The served broadcast takes its epoch from the caller or mints one; it never - claims the source's epoch, since it is served at another path. -- Each stored sequence replays in its recorded form: a group as a group, a - datagram as a datagram (`track::Producer::insert_datagram`), so the replay - keeps the source's lossy, uncached datagram contract. -- A track's `end`, reached by its `StartAfter` refresh since it sorts after - every object key, ends it as recorded. A clean end mirrors - `moq_archive::Reader::finish` today: refreshing stops, the track is - finished at the recorded end (`track::Producer::finish_at`), and - committed groups stay servable. An error end serves the stored groups and - then aborts the track with the recorded code (`StreamError::from_code`), - failing loud as the source did rather than reading as live. The - broadcast's `.end`, seen by the delimiter LIST, stops the search for - added tracks. The producer writes every marker only once the broadcast - has ended, so a live consumer finishes tracks only then; until then an - ended track just stops growing, and a re-added one grows again. Without markers the - archive is live and followed through refresh, which is how a producer - that crashed or was stopped reads. This replaces the caller-driven - `Reader::finish`. - -Guidance: - -- An absent group below the newest stored one is a gap and reports as dropped. - A group below the oldest stored key was pruned or never stored and reports - the same way; history-mode callers rely on the start answering with what - remains ([History from the retained floor](/quest/m1/archive/history-floor.md)). -- Keep a byte-bounded object LRU so neighbouring groups come from one GET. -- A `track::Fill` names group positions only, so a `Time(pts)` end the cache - can't resolve never reaches the handler. Resolving one from the key listing - (the object, then the group containing pts from its group timestamps) needs - a fill that carries the anchor. -- Move `rs/moq-hls/src/export/archive_tests.rs` onto a consumer broadcast with - its archived catalog, deleting the hand-built catalog. -- Update `doc/bin/cli.md` and `doc/bin/hls.md` inline with an export, import, - and HLS example. - -Test with the in-memory store: a round trip preserving structure and payloads -byte for byte, a gap, a pruned start, a growing archive followed through -refresh (including two tracks where a new key sorts before the other track's -cursor, and a track added mid-recording), history older than the source's max -age still served, an untimed track, an interleaved group and datagram track -replayed in its original forms, a completed archive whose replay ends at -each track's recorded end (including mid-group), an errored track -replaying its stored groups and then the recorded error, a track removed -and re-added under its name replaying both runs in sequence, a growing archive -with no markers staying open, and an empty or unrecognized prefix refused loudly. - -Public API: `archive::Consumer` replaces `Reader`. Wire: none. diff --git a/quest/m1/archive/history-floor.md b/quest/m1/archive/history-floor.md index 50d6f0f2a17..394480e4c95 100644 --- a/quest/m1/archive/history-floor.md +++ b/quest/m1/archive/history-floor.md @@ -14,7 +14,7 @@ never listing as a complete recording. Decided 2026-10-11 (#5236): refuse pruned recordings now, and quest the floor. Decided 2026-10-17: kept as its own `moq-hls` quest rather than folded into -the [Archive consumer](/quest/m1/archive/consumer.md). The consumer only +the archive consumer. The consumer only answers a group below its oldest stored key as dropped; this quest makes history mode start there. The old Reader's timeline-map pruning and any retention field in the catalog are gone with the reader and the root @@ -33,7 +33,6 @@ stored segment, and a hole after it still fails. ## Required -- [Archive consumer](/quest/m1/archive/consumer.md) - serves the pruned archive and drops groups below its floor - [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the `hls` records history mode reads ## Related diff --git a/quest/m2/archive-dvr.md b/quest/m2/archive-dvr.md index 27c5fa271fe..b1f29bd5dd6 100644 --- a/quest/m2/archive-dvr.md +++ b/quest/m2/archive-dvr.md @@ -31,4 +31,4 @@ over missing groups, and return to live. ## Required - [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - the `Time(pts)` range a seek sends -- [Archive consumer](/quest/m1/archive/consumer.md) - serves a stored broadcast and resolves time ranges from its keys +- [Archive time ranges](/quest/m2/archive-time-ranges.md) - the archive consumer resolves time ranges from its keys diff --git a/quest/m2/archive-time-ranges.md b/quest/m2/archive-time-ranges.md new file mode 100644 index 00000000000..1a548314354 --- /dev/null +++ b/quest/m2/archive-time-ranges.md @@ -0,0 +1,30 @@ +# [M] Archive time ranges + +## Goal + +A `Time(pts)` range on an `archive::Consumer` broadcast resolves from the +archive's key listing, not only from the groups the consumer's cache holds: +a start reaches the stored group containing pts, and an end stops just after +it, however old. + +## Plan + +The consumer creates each track live from its newest stored group and serves +older groups through `track::Fill`, so its cache holds almost nothing a time +anchor can resolve against: today a `Time(pts)` start older than the newest +group resolves to the oldest cached group instead. A `Fill` names group +positions only, so an anchor the cache can't resolve never reaches the +handler. [moq-dev/moq#6171](https://github.com/moq-dev/moq/pull/6171) deferred carrying the anchor to the consumer quest, which +landed without it. + +- `moq-net` passes a time anchor the cache can't resolve to the + `track::Dynamic` handler, unresolved, in its fill. +- The consumer resolves it from its index: every object key carries its first + frame's pts, so the object holding pts is one lookup, and the group within + it comes from the stored group timestamps. + +Test with the in-memory store: a start and an end older than the newest +group, an anchor before the oldest stored object, an anchor in a gap, and an +untimed track refusing it. + +Public API: `track::Fill` gains the anchor. Wire: none. diff --git a/quest/m3/archive-paced-replay.md b/quest/m3/archive-paced-replay.md index b5a7f74bb50..d43f86e636b 100644 --- a/quest/m3/archive-paced-replay.md +++ b/quest/m3/archive-paced-replay.md @@ -10,7 +10,7 @@ viewer who joins late joins mid-replay. ## Plan -Retargeted 2026-10-17 onto [Archive consumer](/quest/m1/archive/consumer.md), +Retargeted 2026-10-17 onto the archive consumer, whose broadcast already includes the archived catalog as an ordinary track. - Decided 2026-10-08: the catalog paces on the same clock as the media, each @@ -36,7 +36,3 @@ whose broadcast already includes the archived catalog as an ordinary track. current group, and that two tracks stay aligned. - Deferred in the 2026-09-30 audit and moved to m3 in the 2026-10-05 audit: no named consumer. - -## Required - -- [Archive consumer](/quest/m1/archive/consumer.md) - the stored broadcast this paces diff --git a/rs/moq-archive/MIGRATE.md b/rs/moq-archive/MIGRATE.md index 35fa7a38048..73f9d3e0058 100644 --- a/rs/moq-archive/MIGRATE.md +++ b/rs/moq-archive/MIGRATE.md @@ -4,6 +4,16 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Consumer` replaces `Reader` + +`Reader`, `reader::Config`, and the timeline layout it read are removed; serve an archive with `Consumer::open(store, broadcast, consumer::Config::default()).await?` and then `run().await`. +`open` creates every stored track on the `broadcast::Producer` you pass, so announce it afterwards, with an epoch you pick; `run` follows a growing archive until its `.end` and serves until the broadcast closes, replacing `Reader::{track, refresh, finish, serve}`. +`Consumer` reads only the per-track layout `Producer` writes; an old timeline recording holds keys it does not recognize and is refused. +The timeline layout's API goes with it: `Key`, `Frame`, `Group`, `Object`, the `segment` module, `store::list`, and `Store::{path, put_segments, get_segments, timelines, delete, list, list_paginated}`; use the `object_store` API on `Store::inner` for raw access. +`Store::put_info` returns `()`. +`.info` refuses an `epoch` member, which an older `Writer` stored. +`Error::{Span, Directory, Pagination}` are removed. + ### `Producer` replaces `Writer` (#6160) `Writer`, its `Control`, and `writer::{Config, Retention}` are removed; record with `Producer::new(store, broadcast, producer::Config::default()).run().await` instead. diff --git a/rs/moq-archive/README.md b/rs/moq-archive/README.md index af917e9a37f..c29d0f43833 100644 --- a/rs/moq-archive/README.md +++ b/rs/moq-archive/README.md @@ -14,6 +14,12 @@ group becomes a gap instead of a stall, a restarted producer resumes each track where it stopped, and an optional DVR window prunes old objects. The host picks the prefix and runs one producer per broadcast. +`Consumer` serves a stored prefix back as an ordinary broadcast: every track, +the archived catalog included, with its original groups, frames, timescale, +and priority. Each track is live from its newest stored group, older groups +are fetched from the store on request, and a growing archive is followed until +its producer marks it complete. + ```bash cargo add moq-archive ``` @@ -23,7 +29,11 @@ use moq_archive::object_store::memory::InMemory; // `broadcast` is a `moq_net::broadcast::Consumer`. let store = moq_archive::Store::new(InMemory::new(), "recordings/demo"); -moq_archive::Producer::new(store, broadcast, Default::default()).run().await?; +moq_archive::Producer::new(store.clone(), broadcast, Default::default()).run().await?; + +// `served` is a `moq_net::broadcast::Producer`, announced once `open` returns. +let consumer = moq_archive::Consumer::open(store, served, Default::default()).await?; +consumer.run().await?; ``` See [docs.rs/moq-archive](https://docs.rs/moq-archive) for the object layout. diff --git a/rs/moq-archive/src/consumer/index.rs b/rs/moq-archive/src/consumer/index.rs index 790744cba9d..a93e50ae47a 100644 --- a/rs/moq-archive/src/consumer/index.rs +++ b/rs/moq-archive/src/consumer/index.rs @@ -61,7 +61,9 @@ impl Index { /// The newest object, which holds the newest stored sequence. pub fn newest(&self) -> Option<(Start, Path)> { - self.objects.last_key_value().map(|(start, path)| (*start, path.clone())) + self.objects + .last_key_value() + .map(|(start, path)| (*start, path.clone())) } /// The objects holding group `sequence` from frame `frame` on, oldest first: the last one diff --git a/rs/moq-archive/src/consumer/mod.rs b/rs/moq-archive/src/consumer/mod.rs index ecceff7b141..fcaf1d141a8 100644 --- a/rs/moq-archive/src/consumer/mod.rs +++ b/rs/moq-archive/src/consumer/mod.rs @@ -4,7 +4,7 @@ //! stored track on a `moq_net` broadcast, the archived catalog included, with its stored //! timescale and priority and no max age: the store's retention bounds what it holds. Each //! track's live feed starts at its newest stored sequence, and every older group is served on -//! demand from the store through [`track::Dynamic`] fills, each sequence in its recorded form: a +//! demand from the store through [`track::Dynamic`](moq_net::track::Dynamic) fills, each sequence in its recorded form: a //! group as a group, a datagram as a datagram. //! //! A growing archive is followed: every [`Config::refresh`], one delimiter LIST of the prefix @@ -38,8 +38,8 @@ use std::time::Duration; use futures::FutureExt; use futures::future::BoxFuture; use moq_net::broadcast; -use object_store::{ObjectStore, ObjectStoreExt}; use object_store::path::Path; +use object_store::{ObjectStore, ObjectStoreExt}; use crate::layout::{self, End, Object}; use crate::{Error, Result, Store}; diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index 72a74975f6c..c2ab49dcff9 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -191,7 +191,9 @@ async fn a_recording_round_trips() { .with_priority(3) .with_max_age(Duration::from_secs(1)); let video = source.create_track("video", timed).unwrap(); - let mut log = source.create_track("log", track::Info::default().with_priority(9)).unwrap(); + let mut log = source + .create_track("log", track::Info::default().with_priority(9)) + .unwrap(); let config = crate::producer::Config::default() .with_duration(Duration::from_secs(2)) .with_flush(Duration::from_secs(100)); @@ -203,7 +205,9 @@ async fn a_recording_round_trips() { let mut group = video.create_group(group::Info { sequence }).unwrap(); for index in 0..3 { let pts = Timestamp::from_millis(sequence * 10_000 + index).unwrap(); - group.write_frame(pts, Bytes::from(vec![sequence as u8, index as u8, 0xff])).unwrap(); + group + .write_frame(pts, Bytes::from(vec![sequence as u8, index as u8, 0xff])) + .unwrap(); } group.finish().unwrap(); sleep(Duration::from_millis(10)).await; @@ -273,7 +277,11 @@ async fn groups_come_from_cached_objects_and_span_them() { let archive = Archive::new(); archive.track("video", 0, true).await; archive - .object("video", 0, vec![frames(true, 0, 0..2), frames(true, 1, 0..2), frames(true, 2, 0..3)]) + .object( + "video", + 0, + vec![frames(true, 0, 0..2), frames(true, 1, 0..2), frames(true, 2, 0..3)], + ) .await; archive.object("video", 3, vec![frames(true, 2, 3..5)]).await; archive.object("video", 0, vec![frames(true, 3, 0..1)]).await; @@ -318,7 +326,9 @@ async fn a_gap_reads_as_dropped() { async fn a_pruned_start_reads_as_dropped() { let archive = Archive::new(); archive.track("video", 0, true).await; - archive.object("video", 2, vec![frames(true, 5, 2..4), frames(true, 6, 0..2)]).await; + archive + .object("video", 2, vec![frames(true, 5, 2..4), frames(true, 6, 0..2)]) + .await; archive.object("video", 0, vec![frames(true, 7, 0..2)]).await; archive.object("video", 0, vec![frames(true, 8, 0..1)]).await; @@ -353,7 +363,9 @@ async fn a_growing_archive_is_followed() { archive.object("a", 0, vec![frames(true, 1, 0..2)]).await; archive.object("b", 0, vec![frames(true, 51, 0..1)]).await; // A new object continuing the open group 51. - archive.object("b", 1, vec![frames(true, 51, 1..2), frames(true, 52, 0..1)]).await; + archive + .object("b", 1, vec![frames(true, 51, 1..2), frames(true, 52, 0..1)]) + .await; assert_eq!(next(&mut a).await.unwrap(), (0, expected(true, 0, 0..1))); assert_eq!(sequence(&mut a).await, Some(1)); assert_eq!(next(&mut b).await.unwrap(), (51, expected(true, 51, 0..2))); @@ -375,7 +387,9 @@ async fn a_growing_archive_is_followed() { async fn an_archive_without_markers_stays_open() { let archive = Archive::new(); archive.track("video", 0, true).await; - archive.object("video", 0, vec![frames(true, 0, 0..1), frames(true, 1, 0..1)]).await; + archive + .object("video", 0, vec![frames(true, 0, 0..1), frames(true, 1, 0..1)]) + .await; // A track `end` alone is not final: the broadcast never wrote `.end`. archive.end("video", End::Finish(At { group: 2, frame: 0 })).await; @@ -396,16 +410,25 @@ async fn a_completed_archive_ends_as_recorded() { archive.track("video", 0, true).await; archive.track("audio", 0, true).await; archive.track("errored", 0, true).await; - archive.object("video", 0, vec![frames(true, 0, 0..2), frames(true, 1, 0..2)]).await; - archive.object("audio", 0, vec![frames(true, 0, 0..2), frames(true, 1, 0..2)]).await; - archive.object("errored", 0, vec![frames(true, 0, 0..1), frames(true, 1, 0..1)]).await; + archive + .object("video", 0, vec![frames(true, 0, 0..2), frames(true, 1, 0..2)]) + .await; + archive + .object("audio", 0, vec![frames(true, 0, 0..2), frames(true, 1, 0..2)]) + .await; + archive + .object("errored", 0, vec![frames(true, 0, 0..1), frames(true, 1, 0..1)]) + .await; archive.end("video", End::Finish(At { group: 2, frame: 0 })).await; archive.end("audio", End::Finish(At { group: 1, frame: 2 })).await; archive.end("errored", End::Error(64 + 7)).await; archive.complete().await; let (broadcast, _run) = serve(&archive.store).await; - for (track, end) in [("video", Position::group(2)), ("audio", Position { group: 1, frame: 2 })] { + for (track, end) in [ + ("video", Position::group(2)), + ("audio", Position { group: 1, frame: 2 }), + ] { let mut reader = subscribe(&broadcast, track).await; assert_eq!(next(&mut reader).await.unwrap(), (0, expected(true, 0, 0..2))); assert_eq!(next(&mut reader).await.unwrap(), (1, expected(true, 1, 0..2))); @@ -441,7 +464,11 @@ async fn groups_and_datagrams_keep_their_forms() { let archive = Archive::new(); archive.track("mixed", 0, false).await; archive - .object("mixed", 0, vec![frames(false, 0, 0..1), datagram(false, 1), frames(false, 2, 0..1)]) + .object( + "mixed", + 0, + vec![frames(false, 0, 0..1), datagram(false, 1), frames(false, 2, 0..1)], + ) .await; let (broadcast, _run) = serve(&archive.store).await; @@ -452,7 +479,9 @@ async fn groups_and_datagrams_keep_their_forms() { .with_max_delay(None); let mut reader = track.subscribe(subscription).await.unwrap(); - archive.object("mixed", 0, vec![datagram(false, 3), frames(false, 4, 0..1)]).await; + archive + .object("mixed", 0, vec![datagram(false, 3), frames(false, 4, 0..1)]) + .await; let datagram = timeout(10 * REFRESH, reader.recv_datagram()) .await .expect("the datagram arrives") @@ -468,7 +497,11 @@ async fn groups_and_datagrams_keep_their_forms() { // The newest stored group arrives live as a group, still open. let mut sequences = Vec::new(); while sequences.last() != Some(&4) { - let group = timeout(10 * REFRESH, reader.recv_group()).await.unwrap().unwrap().unwrap(); + let group = timeout(10 * REFRESH, reader.recv_group()) + .await + .unwrap() + .unwrap() + .unwrap(); sequences.push(group.sequence); } assert!(!sequences.contains(&1) && !sequences.contains(&3), "{sequences:?}"); @@ -534,7 +567,9 @@ async fn an_empty_or_unrecognized_prefix_is_refused() { // A directory that is not a canonical track name. let archive = Archive::new(); - archive.raw("catalog.json/.info", br#"{"version":2,"priority":0}"#).await; + archive + .raw("catalog.json/.info", br#"{"version":2,"priority":0}"#) + .await; assert!(matches!(open(archive.store.clone()).await, Some(Error::Track))); } @@ -551,7 +586,10 @@ async fn an_unordered_listing_is_sorted() { let (broadcast, _run) = serve(&archive.store).await; let mut reader = subscribe(&broadcast, "video").await; for sequence in 0..3 { - assert_eq!(next(&mut reader).await.unwrap(), (sequence, expected(true, sequence, 0..1))); + assert_eq!( + next(&mut reader).await.unwrap(), + (sequence, expected(true, sequence, 0..1)) + ); } assert_eq!(super::tests::sequence(&mut reader).await, Some(3)); } diff --git a/rs/moq-archive/src/consumer/track.rs b/rs/moq-archive/src/consumer/track.rs index 07273b51ad2..4c747103216 100644 --- a/rs/moq-archive/src/consumer/track.rs +++ b/rs/moq-archive/src/consumer/track.rs @@ -191,7 +191,10 @@ async fn serve_group(shared: &Shared, index: &Mutex, f return; }; let frames = match &first.entries[position] { - Entry::Group { sequence: found, frames } if *found == sequence => frames, + Entry::Group { + sequence: found, + frames, + } if *found == sequence => frames, // A datagram stays lossy and uncached, never a fetched group. Entry::Datagram { sequence: found, .. } if *found == sequence => { fill.drop_groups(sequence..sequence + 1); @@ -222,7 +225,10 @@ async fn serve_group(shared: &Shared, index: &Mutex, f for object in &objects[1..] { let next = start + stored.len() as u64; match object.entries.first() { - Some(Entry::Group { sequence: found, frames }) if *found == sequence && object.frame_start == next => { + Some(Entry::Group { + sequence: found, + frames, + }) if *found == sequence && object.frame_start == next => { stored.extend((next..).zip(frames)); } _ => { diff --git a/rs/moq-cli/MIGRATE.md b/rs/moq-cli/MIGRATE.md index c52537abb08..a45816a0e8f 100644 --- a/rs/moq-cli/MIGRATE.md +++ b/rs/moq-cli/MIGRATE.md @@ -4,12 +4,17 @@ How to update your invocation and config for each breaking change, newest first. ## Unreleased +### `import archive` reads what `export archive` writes + +`moq import archive ` replays one recorded broadcast, the `//@` prefix `export archive` wrote, every track included; point `` at that prefix. +It no longer reads timeline recordings made by older `moq` versions. +`--follow ` is removed: a recording still being made is always followed, until `export archive` marks it complete; set the check interval with `--refresh` (default 1s). + ### `export archive` records a prefix (#6160) `moq export archive ` records every broadcast announced at or under `--broadcast`, each instance under `//@`, until the connection ends, instead of one broadcast at ``. Point `` at the root you want recordings under, and drop `--catalog-format`, which it now refuses. Untimed tracks record, and `--retention` works on them. -`import archive` does not read the new layout yet; it still replays recordings made by older `moq` versions. ### `--cluster-lan-secret` is a file path only (#5910) diff --git a/rs/moq-cli/src/archive.rs b/rs/moq-cli/src/archive.rs index 4c8aa754b54..68317ac4aa5 100644 --- a/rs/moq-cli/src/archive.rs +++ b/rs/moq-cli/src/archive.rs @@ -1,5 +1,5 @@ //! Archive endpoints: record every broadcast announced under a prefix into an object store -//! (export), or republish a recording from one (import), through `moq-archive`. +//! (export), or republish one recorded broadcast from one (import), through `moq-archive`. use std::collections::HashMap; use std::sync::Arc; @@ -38,13 +38,12 @@ pub struct ExportArgs { #[derive(usage_rs::Args, Clone)] #[usage(unknown_flags = "error", args_override_self = false)] pub struct ImportArgs { - /// The timeline recording to replay. + /// One recorded broadcast: the `/@` key prefix `export archive` wrote. pub store: Url, - /// Keep following a recording that is still growing, checking for new segments at this - /// interval. Unset replays what is stored now and ends the timeline there. + /// How often a recording still growing is checked for new objects and tracks. Defaults to 1s. #[usage(long)] - pub follow: Option, + pub refresh: Option, } /// One publisher instance being recorded: its path, and its epoch when its route has one. @@ -239,8 +238,9 @@ fn key(root: &Path, (path, epoch): &Instance) -> anyhow::Result { /// Republish the recording at `args.store` as the broadcast `name`, announced under `epoch`. /// -/// Each track's timeline replays as a live track and every other track's groups are served on -/// request. +/// Every stored track is served with its original groups: each live from its newest stored +/// group, and history on request. A recording still growing is followed until its producer +/// marks it complete. pub async fn import( origin: moq_net::origin::Producer, name: String, @@ -248,48 +248,24 @@ pub async fn import( epoch: moq_net::Epoch, ) -> anyhow::Result<()> { let store = open(&args.store)?; - // A second handle lists the recording's timelines while the reader owns the first. - let listing = open(&args.store)?; + let mut config = moq_archive::consumer::Config::default(); + if let Some(refresh) = args.refresh { + config = config.with_refresh(refresh.into_std()); + } let broadcast = origin.create_broadcast(&name).context("failed to create broadcast")?; - let mut timelines = store - .timelines() - .await - .with_context(|| format!("failed to list the recording at {}", args.store))?; - let config = moq_archive::reader::Config::new(timelines.clone()); - let mut reader = moq_archive::Reader::open(store, &broadcast, config) + let consumer = moq_archive::Consumer::open(store, broadcast.clone(), config) .await .with_context(|| format!("no readable recording at {}", args.store))?; - let serve = reader.serve(); broadcast .announce(moq_net::origin::Route::default().with_epoch(epoch)) .context("failed to announce broadcast")?; tracing::info!(%name, store = %args.store, "replaying"); notify_ready(); - - // The store holds no end marker, so only the caller can say the recording is complete. - let Some(interval) = args.follow.map(crate::duration::Duration::into_std) else { - reader.finish()?; - serve.await; - return Ok(()); - }; - let refresh = async { - loop { - tokio::time::sleep(interval).await; - // A rendition the recording enrolled after the replay started. - for (track, timeline) in listing.timelines().await? { - if let std::collections::btree_map::Entry::Vacant(entry) = timelines.entry(track) { - reader.track(entry.key(), &timeline).await?; - entry.insert(timeline); - } - } - reader.refresh().await?; - } - }; - tokio::select! { - () = serve => Ok(()), - result = refresh => result, - } + consumer + .run() + .await + .with_context(|| format!("replaying {} failed", args.store)) } /// Open the object store `url` names, taking cloud credentials from the `AWS_*`, `GOOGLE_*`, @@ -303,7 +279,7 @@ fn open_store(url: &Url) -> anyhow::Result<(Arc, Path)> { Ok((Arc::from(store), prefix)) } -/// [`open_store`] as one recording. +/// [`open_store`] as one recorded broadcast. fn open(url: &Url) -> anyhow::Result>> { let (store, prefix) = open_store(url)?; Ok(moq_archive::Store::new(store, prefix)) @@ -406,6 +382,63 @@ mod tests { assert!(!files.iter().any(|file| file.starts_with("other")), "{files:?}"); } + /// `import archive` republishes a recording `export archive` wrote under the given name and + /// epoch, serving its stored groups. + #[tokio::test] + async fn an_exported_broadcast_imports() { + let _env = crate::test_env::EnvGuard::clear(&[]); + let dir = tempfile::tempdir().unwrap(); + let url = Url::from_directory_path(dir.path()).unwrap(); + + let source = moq_net::broadcast::Info::new().produce(); + let info = track::Info::default().with_timescale(Timescale::MILLI); + let video = source.create_track("video", info).unwrap(); + for sequence in 0..2 { + let mut group = video.create_group(group::Info { sequence }).unwrap(); + let pts = Timestamp::from_millis(sequence * 1000).unwrap(); + group.write_frame(pts, format!("frame {sequence}")).unwrap(); + group.finish().unwrap(); + } + video.finish().unwrap(); + let recording = + tokio::spawn(moq_archive::Producer::new(open(&url).unwrap(), source.consume(), Default::default()).run()); + // Its objects, then the markers once the broadcast ends. + tokio::time::timeout(Duration::from_secs(10), async { + while files(dir.path()).len() < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the recording stores its track"); + source.close(); + recording.await.unwrap().unwrap(); + + let origin = moq_tokio::origin::spawn(); + let args = ImportArgs { + store: url, + refresh: None, + }; + let epoch = moq_net::Epoch::mint(); + let _import = tokio::spawn(import(origin.clone(), "replay".into(), args, epoch.clone())); + let mut announced = origin.consume().announced(); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Some(moq_net::announce::Event::Start(announce)) = announced.next().await { + assert_eq!(announce.prefix.as_str(), "replay"); + assert_eq!(announce.route.epoch, Some(epoch.clone())); + break; + } + } + }) + .await + .expect("the replay is announced"); + let broadcast = origin.consume().request_broadcast("replay", None).await.unwrap(); + let track = broadcast.track("video").unwrap(); + let mut group = track.fetch_group(0, None).await.unwrap(); + let frame = group.read_frame().await.unwrap().unwrap(); + assert_eq!(&frame.payload[..], b"frame 0"); + } + /// An epochless route announced again while its last recording drains is a new recording, and /// an epoch-pinned one records again once its recording ends. #[test] diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs index bb7a77db592..aef91e1250b 100644 --- a/rs/moq-hls/src/export/archive_tests.rs +++ b/rs/moq-hls/src/export/archive_tests.rs @@ -1,35 +1,37 @@ //! Serving a `moq-archive` recording as HLS. //! -//! A [`moq_archive::Reader`] replays each track's timeline onto a broadcast and answers FETCH from -//! its record objects, so the exporter serves it exactly like a live broadcast. These tests pin -//! the storage traffic that composition produces: playlists read only the timelines, and a segment -//! GETs only objects of the requested rendition. +//! A source broadcast, its catalog and timelines included, is recorded by an +//! [`moq_archive::Producer`] and served back by an [`moq_archive::Consumer`], so the exporter +//! serves the archived catalog exactly like a live broadcast. These tests pin the storage traffic +//! that composition produces: playlists read only the timelines, and a segment GETs only objects +//! of the requested rendition. use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use bytes::{Bytes, BytesMut}; +use futures::TryStreamExt; use futures::stream::BoxStream; use hang::timeline::{Position, Record}; +use moq_archive::Store; use moq_archive::object_store::memory::InMemory; use moq_archive::object_store::path::Path; use moq_archive::object_store::{ - self, CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, + self, CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, ObjectStoreExt, PutMultipartOptions, PutOptions, PutPayload, PutResult, }; -use moq_archive::{Frame, Group, Info, Object, Store, reader}; use moq_json::window; +use moq_net::{Timescale, Timestamp, group, track}; use super::*; -/// In-memory store that records every GET path and counts listings. +/// In-memory store that records every GET path. #[derive(Debug, Clone, Default)] struct Counting { inner: Arc, gets: Arc>>, - lists: Arc, /// Fail every media GET, so nothing can quietly depend on one. reject_media: Arc, } @@ -40,11 +42,6 @@ impl Counting { std::mem::take(&mut *self.gets.lock().unwrap()) } - /// Listings since the store was created. - fn lists(&self) -> usize { - self.lists.load(Ordering::SeqCst) - } - fn reject_media(&self, reject: bool) { self.reject_media.store(reject, Ordering::SeqCst); } @@ -94,7 +91,6 @@ impl ObjectStore for Counting { } fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, object_store::Result> { - self.lists.fetch_add(1, Ordering::SeqCst); self.inner.list(prefix) } @@ -107,55 +103,105 @@ impl ObjectStore for Counting { } } -/// One track's timeline encoder, as `moq_archive::Writer` drives it. -struct Encoder { +/// One rendition of the source: its media track, and its timeline track with the window encoder +/// that writes it. +struct Source { + media: track::Producer, + timeline: track::Producer, encoder: window::Encoder, + /// The timeline group still open, which a pop may extend. + group: Option, /// Next timeline group sequence. - group: u64, + next: u64, /// Next record sequence. record: u64, } -/// Writes recording objects the way `moq_archive::Writer` lays them out, with full control over -/// each record's timing and groups. +/// A source broadcast recorded into a store as it publishes. struct Recording { store: Store, - encoders: HashMap, + broadcast: moq_net::broadcast::Producer, + catalog: moq_json::snapshot::Producer, + sources: HashMap, + recorder: tokio::task::JoinHandle>, } impl Recording { - async fn new(tracks: &[&str]) -> Self { + /// `archive` is the catalog's root entry; `checkpoint` bounds each timeline's checkpoint. + async fn new(archive: hang::catalog::Archive, checkpoint: Option) -> Self { let store = Store::new(Counting::default(), "rec"); - let mut encoders = HashMap::new(); - for track in tracks { + let broadcast = moq_net::broadcast::Info::new().produce(); + let track = broadcast + .create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info()) + .unwrap(); + let mut json = moq_json::snapshot::Config::default(); + json.delta_ratio = 0; + let mut catalog = moq_json::snapshot::Producer::new(track, json); + catalog + .update(moq_net::Timed::from(&self::catalog(archive)).at(Timestamp::now())) + .unwrap(); + + let mut sources = HashMap::new(); + for name in ["360p", "1080p", "audio"] { // Legacy media is stamped in microseconds. - store - .put_info(track, &Info::new(1, Some(1_000_000)).unwrap()) - .await - .unwrap(); - store - .put_info(&timeline(track), &Info::new(0, Some(1000)).unwrap()) - .await - .unwrap(); - let config = window::ProducerConfig::default() + let media = track::Info::default() + .with_timescale(Timescale::new(1_000_000).unwrap()) + .with_priority(1); + let media = broadcast.create_track(name, media).unwrap(); + let timeline = track::Info::default().with_timescale(Timescale::MILLI); + let timeline = broadcast.create_track(timeline_name(name), timeline).unwrap(); + let mut config = window::ProducerConfig::default() .with_compression(true) .with_op_ratio(0); - let encoder = Encoder { + if let Some(limit) = checkpoint { + config = config.with_checkpoint_records(limit); + } + let source = Source { + media, + timeline, encoder: window::Encoder::new(config), - group: 0, + group: None, + next: 0, record: 0, }; - encoders.insert(track.to_string(), encoder); + sources.insert(name.to_string(), source); + } + + // The source publishes in bursts, so the budget covers a whole recording. + let config = moq_archive::producer::Config::default() + .with_flush(Duration::from_millis(100)) + .with_max_delay(Duration::from_secs(3600)); + let recorder = tokio::spawn(moq_archive::Producer::new(store.clone(), broadcast.consume(), config).run()); + // Subscribed before anything is published, so every group is recorded live. + for source in sources.values() { + while source.media.subscription().is_none() || source.timeline.subscription().is_none() { + tokio::time::sleep(Duration::from_millis(1)).await; + } + } + Self { + store, + broadcast, + catalog, + sources, + recorder, } - Self { store, encoders } } - /// Store and commit `track`'s next record: `groups`, each a list of frame timestamps in micros, + /// Publish `track`'s next record: `groups`, each a list of frame timestamps in micros, /// spanning `pts..pts + duration` in milliseconds, popping `pop` older records. - async fn add(&mut self, track: &str, pts: u64, duration: u64, groups: &[(u64, &[u64])], pop: u64) { - let encoder = self.encoders.get_mut(track).unwrap(); - let sequence = encoder.record; - encoder.record += 1; + fn add(&mut self, track: &str, pts: u64, duration: u64, groups: &[(u64, &[u64])], pop: u64) { + let source = self.sources.get_mut(track).unwrap(); + for &(sequence, frames) in groups { + let mut group = source.media.create_group(group::Info { sequence }).unwrap(); + for (index, µs) in frames.iter().enumerate() { + let timestamp = Timestamp::from_micros(micros).unwrap(); + group.write_frame(timestamp, legacy(track, micros, index == 0)).unwrap(); + } + group.finish().unwrap(); + } + + let sequence = source.record; + source.record += 1; let first = groups.first().unwrap().0; let last = groups.last().unwrap().0; let record = Record::new( @@ -165,100 +211,91 @@ impl Recording { Position::group(first), Position::group(last + 1), ); - - let groups = groups - .iter() - .map(|&(sequence, frames)| Group { - sequence, - frames: frames - .iter() - .enumerate() - .map(|(index, µs)| legacy(track, micros, index == 0)) - .collect(), - }) - .collect(); - self.store - .put_segments(track, sequence, &Object::new(groups)) - .await - .unwrap(); - let mut payloads = Vec::new(); - let pending = encoder.encoder.push(&record).unwrap(); + let pending = source.encoder.push(&record).unwrap(); payloads.push((pending.keyframe, pending.payload.clone())); pending.commit(); - if let Some(pending) = encoder.encoder.pop(pop).unwrap() { + if let Some(pending) = source.encoder.pop(pop).unwrap() { payloads.push((pending.keyframe, pending.payload.clone())); pending.commit(); } - let mut groups: Vec = Vec::new(); for (keyframe, payload) in payloads { - if keyframe || groups.is_empty() { - groups.push(Group { - sequence: encoder.group, - frames: Vec::new(), - }); - encoder.group += 1; + if keyframe || source.group.is_none() { + if let Some(mut group) = source.group.take() { + group.finish().unwrap(); + } + let group = source + .timeline + .create_group(group::Info { sequence: source.next }) + .unwrap(); + source.group = Some(group); + source.next += 1; } - let frame = Frame { - timestamp: record.pts, - payload, - }; - groups.last_mut().unwrap().frames.push(frame); + let timestamp = Timestamp::from_millis(pts).unwrap(); + source.group.as_mut().unwrap().write_frame(timestamp, payload).unwrap(); + } + } + + /// Wait until the recorder subscribed to every track and stored what was published. + async fn stored(&self) { + tokio::time::sleep(Duration::from_secs(5)).await; + } + + /// End the source cleanly, then wait for the recorder to mark the archive complete. + async fn finish(mut self) -> Store { + self.catalog.finish().unwrap(); + for source in self.sources.values_mut() { + if let Some(mut group) = source.group.take() { + group.finish().unwrap(); + } + source.media.finish().unwrap(); + source.timeline.finish().unwrap(); } + self.stored().await; + self.broadcast.close(); + self.recorder.await.unwrap().unwrap(); self.store - .put_segments(&timeline(track), sequence, &Object::new(groups)) - .await - .unwrap(); } /// Every GET since the last call. fn gets(&self) -> Vec { self.store.inner().take() } - - fn timelines(&self) -> std::collections::BTreeMap { - self.encoders - .keys() - .map(|track| (track.clone(), timeline(track))) - .collect() - } } -fn timeline(track: &str) -> String { +fn timeline_name(track: &str) -> String { hang::timeline::default_name(track) } /// One Legacy frame: a VP8 keyframe for video (geometry the muxer can parse), filler otherwise. -fn legacy(track: &str, micros: u64, keyframe: bool) -> Frame { +fn legacy(track: &str, micros: u64, keyframe: bool) -> Bytes { let payload: &'static [u8] = match (track.starts_with("audio"), keyframe) { (true, _) => &[0xFC, 0xFF, 0xFE], (false, true) => &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00], (false, false) => &[0x31, 0x00, 0x00], }; let frame = hang::container::Frame { - timestamp: moq_net::Timestamp::from_micros(micros).unwrap(), + timestamp: Timestamp::from_micros(micros).unwrap(), payload: Bytes::from_static(payload), }; let mut encoded = BytesMut::new(); frame.encode(&mut encoded).unwrap(); - Frame { - timestamp: Some(micros), - payload: encoded.freeze(), - } + encoded.freeze() } /// A live-style archive entry naming every rendition's timeline. fn live() -> hang::catalog::Archive { let mut archive = hang::catalog::Archive::new(); for track in ["360p", "1080p", "audio"] { - archive - .timelines - .insert(track.to_string(), hang::catalog::Timeline::new(timeline(track), 4_000)); + archive.timelines.insert( + track.to_string(), + hang::catalog::Timeline::new(timeline_name(track), 4_000), + ); } archive } -/// The archive entry a replay advertises: the recording's timelines, durable in its store. +/// The archive entry of a source that declares itself durable: its timelines, in a store. fn durable() -> hang::catalog::Archive { let mut archive = live(); archive.store = Some("memory:///rec/".parse().unwrap()); @@ -266,8 +303,8 @@ fn durable() -> hang::catalog::Archive { archive } -/// The catalog an exporter is handed: every rendition's config, plus `archive`. Out-of-band -/// configs, so no init needs media. +/// The source's catalog: every rendition's config, plus `archive`. Out-of-band configs, so no +/// init needs media. fn catalog(archive: hang::catalog::Archive) -> hang::Catalog { let mut catalog = hang::Catalog::default(); catalog.archive = Some(archive); @@ -282,58 +319,40 @@ fn catalog(archive: hang::catalog::Archive) -> hang::Catalog { catalog } -/// A recording served back through an origin: the reader's broadcast carries the supplied -/// catalog, and a broadcaster exports it. +/// An archive served back through an origin by a consumer, and a broadcaster exporting it. struct Replay { - /// Taken to supply finality. - reader: Option>, broadcaster: Arc, - _catalog: moq_json::snapshot::Producer, - _broadcast: moq_net::broadcast::Producer, - _origin: moq_net::origin::Producer, + origin: moq_net::origin::Producer, + consumer: tokio::task::JoinHandle>, + driver: tokio::task::JoinHandle, } impl Replay { - async fn open(recording: &Recording, cache: u64, archive: hang::catalog::Archive) -> Self { - Self::open_with(recording, cache, archive, Config::default()).await + async fn open(store: &Store, cache: u64) -> Self { + Self::open_with(store, cache, Config::default()).await } - async fn open_with(recording: &Recording, cache: u64, archive: hang::catalog::Archive, config: Config) -> Self { + async fn open_with(store: &Store, cache: u64, config: Config) -> Self { let (origin, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); - tokio::spawn(moq_net::time::run(driver)); + let driver = tokio::spawn(moq_net::time::run(driver)); let broadcast = origin.create_broadcast("rec").unwrap(); - - let track = broadcast - .create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info()) - .unwrap(); - let mut json = moq_json::snapshot::Config::default(); - json.delta_ratio = 0; - let mut catalog = moq_json::snapshot::Producer::new(track, json); - catalog - .update(moq_net::Timed::from(&self::catalog(archive)).at(moq_net::Timestamp::now())) - .unwrap(); - - let serving = reader::Config::new(recording.timelines()).with_cache(cache); - let reader = moq_archive::Reader::open(recording.store.clone(), &broadcast, serving) + let serving = moq_archive::consumer::Config::default().with_cache(cache); + let consumer = moq_archive::Consumer::open(store.clone(), broadcast.clone(), serving) .await .unwrap(); - tokio::spawn(reader.serve()); + let consumer = tokio::spawn(consumer.run()); broadcast.announce(Default::default()).unwrap(); - for _ in 0..10 { - tokio::task::yield_now().await; - } let broadcaster = Broadcaster::new(crate::resolved(&origin, "rec").await, config); tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()) .await - .expect("a rendition becomes playable"); + .expect("a rendition becomes playable or fails"); Self { - reader: Some(reader), broadcaster, - _catalog: catalog, - _broadcast: broadcast, - _origin: origin, + origin, + consumer, + driver, } } @@ -381,58 +400,67 @@ impl Replay { } } -fn is_media(path: &str) -> bool { - path.contains("/segments/") && !path.contains("timeline") -} - -/// Three 2s segments: one keyframe group per video record, four audio groups per audio record. -/// The first video rendition by name, 1080p, is the reference. -async fn three_segments() -> Recording { - segments(3).await -} - -/// `count` 2s segments, laid out like [`three_segments`]. -async fn segments(count: u64) -> Recording { - segments_with(count, None).await +impl Drop for Replay { + fn drop(&mut self) { + self.consumer.abort(); + self.driver.abort(); + } } -async fn bounded_segments(count: u64) -> Recording { - segments_with(count, Some(4)).await +/// A stored media object: neither a timeline, the catalog, nor a marker. +fn is_media(path: &str) -> bool { + let name = path.rsplit('/').next().unwrap_or_default(); + let marker = [".info", "end", ".end"].contains(&name); + !marker && !path.contains("timeline") && !path.contains("catalog") +} + +/// The keys of `track`'s stored objects, oldest first, with the first group each holds. +async fn objects(store: &Store, track: &str) -> Vec<(u64, Path)> { + // The names here escape only `.`, as a track directory spells it. + let encoded = track.replace('.', "%2E"); + let dir = Path::parse(format!("rec/{encoded}")).unwrap(); + let metas: Vec = store.inner().list(Some(&dir)).try_collect().await.unwrap(); + let mut objects: Vec<(u64, Path)> = metas + .into_iter() + .filter_map(|meta| { + let name = meta.location.filename()?; + let group = name.split('-').next()?.parse().ok()?; + Some((group, meta.location)) + }) + .collect(); + objects.sort(); + objects } -async fn segments_with(count: u64, checkpoint: Option) -> Recording { - let mut recording = Recording::new(&["360p", "1080p", "audio"]).await; - if let Some(limit) = checkpoint { - for encoder in recording.encoders.values_mut() { - encoder.encoder = window::Encoder::new( - window::ProducerConfig::default() - .with_compression(true) - .with_checkpoint_records(limit) - .with_op_ratio(0), - ); - } - } +/// `count` 2s segments: one keyframe group per video record, four audio groups per audio record. +/// The first video rendition by name, 1080p, is the reference. `checkpoint` bounds each +/// timeline's checkpoint. +async fn segments(count: u64, checkpoint: Option) -> Recording { + let mut recording = Recording::new(durable(), checkpoint).await; for segment in 0..count { - let pts = segment * 2_000_000; - for video in ["360p", "1080p"] { - recording - .add(video, segment * 2000, 2000, &[(segment, &[pts, pts + 1_000_000])], 0) - .await; - } - let audio: Vec<(u64, [u64; 1])> = (0..4).map(|i| (segment * 4 + i, [pts + i * 500_000])).collect(); - let audio: Vec<(u64, &[u64])> = audio - .iter() - .map(|(sequence, frames)| (*sequence, &frames[..])) - .collect(); - recording.add("audio", segment * 2000, 2000, &audio, 0).await; + add_segment(&mut recording, segment, 0); } recording } -#[tokio::test] +fn add_segment(recording: &mut Recording, segment: u64, pop: u64) { + let pts = segment * 2_000_000; + for video in ["360p", "1080p"] { + recording.add(video, segment * 2000, 2000, &[(segment, &[pts, pts + 1_000_000])], pop); + } + let audio: Vec<(u64, [u64; 1])> = (0..4).map(|i| (segment * 4 + i, [pts + i * 500_000])).collect(); + let audio: Vec<(u64, &[u64])> = audio + .iter() + .map(|(sequence, frames)| (*sequence, &frames[..])) + .collect(); + recording.add("audio", segment * 2000, 2000, &audio, pop); +} + +#[tokio::test(start_paused = true)] async fn playlists_read_only_timelines_and_segments_their_own_objects() { - let recording = three_segments().await; - let replay = Replay::open(&recording, 64 * 1024 * 1024, durable()).await; + let recording = segments(3, None).await; + recording.stored().await; + let replay = Replay::open(&recording.store, 64 * 1024 * 1024).await; let master = replay.broadcaster.master_playlist(None); assert!(master.contains("video/360p/media.m3u8") && master.contains("video/1080p/media.m3u8")); @@ -452,54 +480,43 @@ async fn playlists_read_only_timelines_and_segments_their_own_objects() { let line = format!("seg/29b1cd67.{segment}.m4s\n"); assert_eq!(playlist.contains(&line), segment < listed, "{name}: {playlist}"); } - assert!(!playlist.contains("#EXT-X-ENDLIST"), "no finality was supplied"); + assert!(!playlist.contains("#EXT-X-ENDLIST"), "the recording is still growing"); } } let gets = recording.gets(); for rendition in ["360p", "1080p", "audio"] { assert!( gets.contains(&format!("rec/{rendition}/.info")), - "track metadata binds before End: {gets:?}" + "track properties bind before media: {gets:?}" ); } assert!(gets.iter().any(|path| path.contains("timeline")), "{gets:?}"); - assert!( - !gets.iter().any(|path| is_media(path)), - "playlists must not GET media: {gets:?}" - ); recording.store.inner().reject_media(false); - // A segment resolves its objects from the timelines: no listing, no index object. - let lists = recording.store.inner().lists(); - // Switching renditions downloads only the selected rendition's object. - let low = replay - .rendition(Kind::Video, "360p") - .listed_segment(1) - .await - .unwrap() - .unwrap(); - assert_eq!(&low[4..8], b"moof"); - assert_eq!(recording.gets(), ["rec/360p/segments/0000000000000000001"]); - let high = replay - .rendition(Kind::Video, "1080p") - .listed_segment(2) - .await - .unwrap() - .unwrap(); - assert_eq!(&high[4..8], b"moof"); - assert_eq!(recording.gets(), ["rec/1080p/segments/0000000000000000002"]); - - // An audio segment spans four groups but still costs one object GET. - let audio = replay - .rendition(Kind::Audio, "audio") - .listed_segment(1) - .await - .unwrap() - .unwrap(); - assert_eq!(&audio[4..8], b"moof"); - assert_eq!(recording.gets(), ["rec/audio/segments/0000000000000000001"]); + // Switching renditions downloads only the selected rendition's objects. + for (kind, name, segment) in [ + (Kind::Video, "360p", 1), + // Not the newest: a growing archive keeps its newest group open, as a live track does. + (Kind::Video, "1080p", 1), + // An audio segment spans four groups. + (Kind::Audio, "audio", 1), + ] { + let media = replay + .rendition(kind, name) + .listed_segment(segment) + .await + .unwrap() + .unwrap(); + assert_eq!(&media[4..8], b"moof"); + let gets = recording.gets(); + assert!(!gets.is_empty(), "{name}"); + assert!( + gets.iter().all(|path| path.starts_with(&format!("rec/{name}/"))), + "{name}: {gets:?}" + ); + } - // A repeated request hits the reader's cache, including the immutable `.info`. + // A repeated request hits the consumer's cache. replay .rendition(Kind::Video, "360p") .listed_segment(1) @@ -507,18 +524,18 @@ async fn playlists_read_only_timelines_and_segments_their_own_objects() { .unwrap() .unwrap(); assert_eq!(recording.gets(), Vec::::new()); - assert_eq!(recording.store.inner().lists(), lists, "segments never list"); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn a_bounded_cache_rereads_evicted_objects() { - let recording = three_segments().await; + let recording = segments(3, None).await; + recording.stored().await; // Too small for any object, so nothing stays cached. - let replay = Replay::open(&recording, 1, durable()).await; + let replay = Replay::open(&recording.store, 1).await; replay.playlist(Kind::Audio, "audio").await; recording.gets(); - // Each of the segment's four groups misses the cache and GETs the same object again. + // Each of the segment's four groups misses the cache and GETs its object again. let audio = replay .rendition(Kind::Audio, "audio") .listed_segment(1) @@ -527,23 +544,24 @@ async fn a_bounded_cache_rereads_evicted_objects() { .unwrap(); assert_eq!(&audio[4..8], b"moof"); let gets: Vec = recording.gets().into_iter().filter(|path| is_media(path)).collect(); - assert_eq!(gets, vec!["rec/audio/segments/0000000000000000001"; 4]); + assert_eq!(gets.len(), 4, "{gets:?}"); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn missing_rendition_segments_are_gaps_and_time_jumps_are_discontinuities() { - let mut recording = Recording::new(&["360p", "1080p", "audio"]).await; - recording.add("1080p", 0, 2000, &[(0, &[0])], 0).await; - recording.add("360p", 0, 8000, &[(0, &[0])], 0).await; + let mut recording = Recording::new(durable(), None).await; + recording.add("1080p", 0, 2000, &[(0, &[0])], 0); + recording.add("360p", 0, 8000, &[(0, &[0])], 0); + recording.add("audio", 0, 2000, &[(0, &[0])], 0); // 360p stored nothing for segment 1. - recording.add("1080p", 2000, 2000, &[(1, &[2_000_000])], 0).await; + recording.add("1080p", 2000, 2000, &[(1, &[2_000_000])], 0); // Content time jumps from 4s to 10s. - recording.add("1080p", 10_000, 2000, &[(2, &[10_000_000])], 0).await; - recording.add("360p", 10_000, 2000, &[(1, &[10_000_000])], 0).await; + recording.add("1080p", 10_000, 2000, &[(2, &[10_000_000])], 0); + recording.add("360p", 10_000, 2000, &[(1, &[10_000_000])], 0); + // The completed archive resolves each rendition's newest segment. + let store = recording.finish().await; - let mut replay = Replay::open(&recording, 64 * 1024 * 1024, durable()).await; - // Finality resolves each rendition's newest segment. - replay.reader.take().unwrap().finish().unwrap(); + let replay = Replay::open(&store, 64 * 1024 * 1024).await; let low = replay .playlist_until(Kind::Video, "360p", |playlist| playlist.contains("#EXT-X-ENDLIST")) .await; @@ -561,25 +579,26 @@ async fn missing_rendition_segments_are_gaps_and_time_jumps_are_discontinuities( ); // A gap is never fetched. - recording.gets(); + store.inner().take(); let rendition = replay.rendition(Kind::Video, "360p"); assert!(rendition.listed_segment(1).await.unwrap().is_none()); - assert!(!recording.gets().iter().any(|path| is_media(path))); + assert!(!store.inner().take().iter().any(|path| is_media(path))); let after = rendition.listed_segment(2).await.unwrap().unwrap(); assert_eq!(&after[4..8], b"moof"); } -#[tokio::test] -async fn a_growing_recording_ends_only_on_caller_finality() { - let mut recording = three_segments().await; - let mut replay = Replay::open(&recording, 64 * 1024 * 1024, durable()).await; +/// A growing recording is followed as the archive grows, and ends once its producer marks it +/// complete. +#[tokio::test(start_paused = true)] +async fn a_growing_recording_ends_with_its_markers() { + let mut recording = segments(3, None).await; + recording.stored().await; + let replay = Replay::open(&recording.store, 64 * 1024 * 1024).await; replay.playlist(Kind::Video, "1080p").await; // A DVR commit: segment 3 arrives and segment 0 expires. - recording.add("1080p", 6000, 2000, &[(3, &[6_000_000])], 1).await; - recording.gets(); - let mut reader = replay.reader.take().unwrap(); - reader.refresh().await.unwrap(); + add_segment(&mut recording, 3, 1); + recording.stored().await; let playlist = replay .playlist_until(Kind::Video, "1080p", |playlist| { playlist.contains("seg/29b1cd67.3.m4s\n") @@ -589,16 +608,10 @@ async fn a_growing_recording_ends_only_on_caller_finality() { assert!(!playlist.contains("seg/29b1cd67.0.m4s"), "{playlist}"); assert!( !playlist.contains("#EXT-X-ENDLIST"), - "a recording without finality stays live" - ); - let gets = recording.gets(); - assert!( - !gets.iter().any(|path| is_media(path)), - "following reads only the timelines: {gets:?}" + "a recording without markers stays live" ); - // The store holds no completion marker; the caller supplies finality. - reader.finish().unwrap(); + recording.finish().await; let playlist = replay .playlist_until(Kind::Video, "1080p", |playlist| playlist.contains("#EXT-X-ENDLIST")) .await; @@ -607,10 +620,11 @@ async fn a_growing_recording_ends_only_on_caller_finality() { /// A durable timeline still lists only the default 16s window: a live playlist stays bounded /// however much the store retains. -#[tokio::test] +#[tokio::test(start_paused = true)] async fn a_durable_timeline_lists_the_window() { - let recording = segments(12).await; - let replay = Replay::open(&recording, 64 * 1024 * 1024, durable()).await; + let recording = segments(12, None).await; + recording.stored().await; + let replay = Replay::open(&recording.store, 64 * 1024 * 1024).await; let playlist = replay .playlist_until(Kind::Video, "1080p", |playlist| { playlist.contains("seg/29b1cd67.11.m4s\n") @@ -621,35 +635,50 @@ async fn a_durable_timeline_lists_the_window() { assert!(!playlist.contains("seg/29b1cd67.3.m4s\n"), "{playlist}"); } -/// History mode lists a durable timeline's whole recording, and DASH offers the whole listed -/// span. A live-style entry, or a `replay` path that moves the durable ranges to another -/// broadcast, keeps the window even then. -#[tokio::test] -async fn history_mode_lists_a_durable_timeline_past_the_window() { - let recording = bounded_segments(12).await; - let config = Config { +fn history() -> Config { + Config { history: true, ..Config::default() - }; - let replay = Replay::open_with(&recording, 64 * 1024 * 1024, durable(), config.clone()).await; - let playlist = replay - .playlist_until(Kind::Video, "1080p", |playlist| { - playlist.contains("seg/29b1cd67.11.m4s\n") - }) - .await; - assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:0\n"), "{playlist}"); - assert!(playlist.contains("seg/29b1cd67.0.m4s\n"), "{playlist}"); + } +} - for (kind, name) in [(Kind::Video, "360p"), (Kind::Video, "1080p"), (Kind::Audio, "audio")] { +/// History mode lists a durable timeline's whole recording from the archive, and DASH offers +/// the whole listed span. A live-style entry, or a `replay` path that moves the durable ranges to +/// another broadcast, keeps the window even then. +#[tokio::test(start_paused = true)] +async fn history_mode_lists_a_durable_timeline_past_the_window() { + let recording = segments(12, Some(4)).await; + let store = recording.finish().await; + let replay = Replay::open_with(&store, 64 * 1024 * 1024, history()).await; + // Every rendition reads its own timeline's history, not only the reference: otherwise the + // reference's early rows resolve as gaps on its siblings. + for (kind, name) in [(Kind::Video, "1080p"), (Kind::Video, "360p"), (Kind::Audio, "audio")] { + let playlist = replay + .playlist_until(kind, name, |playlist| playlist.contains("#EXT-X-ENDLIST")) + .await; + assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:0\n"), "{name}: {playlist}"); + assert!(playlist.contains(".0.m4s\n"), "{name}: {playlist}"); + assert!(playlist.contains(".11.m4s\n"), "{name}: {playlist}"); + assert!(!playlist.contains("#EXT-X-GAP"), "{name}: {playlist}"); + assert!(replay.rendition(kind, name).listed_segment(0).await.unwrap().is_some()); replay.rendition(kind, name).init().await.unwrap(); } let manifest = replay.broadcaster.manifest(None).unwrap().expect("manifest renders"); - assert!(manifest.contains("timeShiftBufferDepth=\"PT24.000S\""), "{manifest}"); + // The completed recording is a static manifest over the whole span. + assert!( + manifest.contains("mediaPresentationDuration=\"PT24.000S\""), + "{manifest}" + ); let mut elsewhere = durable(); elsewhere.replay = Some(moq_net::path::RelativeOwned::new("./recording")); for archive in [live(), elsewhere] { - let windowed = Replay::open_with(&recording, 64 * 1024 * 1024, archive, config.clone()).await; + let mut recording = Recording::new(archive, Some(4)).await; + for segment in 0..12 { + add_segment(&mut recording, segment, 0); + } + let store = recording.finish().await; + let windowed = Replay::open_with(&store, 64 * 1024 * 1024, history()).await; let playlist = windowed .playlist_until(Kind::Video, "1080p", |playlist| { playlist.contains("seg/29b1cd67.11.m4s\n") @@ -663,13 +692,10 @@ async fn history_mode_lists_a_durable_timeline_past_the_window() { /// becomes a VOD playlist once the requested end is covered. Independent edges agree. #[tokio::test(start_paused = true)] async fn range_playlist_clips_durable_history() { - let recording = bounded_segments(12).await; - let config = Config { - history: true, - ..Config::default() - }; - let first = Replay::open_with(&recording, 64 * 1024 * 1024, durable(), config.clone()).await; - let second = Replay::open_with(&recording, 64 * 1024 * 1024, durable(), config).await; + let recording = segments(12, Some(4)).await; + recording.stored().await; + let first = Replay::open_with(&recording.store, 64 * 1024 * 1024, history()).await; + let second = Replay::open_with(&recording.store, 64 * 1024 * 1024, history()).await; for replay in [&first, &second] { replay .playlist_until(Kind::Video, "1080p", |playlist| { @@ -706,9 +732,10 @@ async fn range_playlist_clips_durable_history() { async fn range_playlist_refuses_non_durable_or_windowed_timelines() { use futures::FutureExt; - let recording = bounded_segments(3).await; let range = super::Range::new(Duration::ZERO, Duration::from_secs(2)).unwrap(); - let windowed = Replay::open(&recording, 64 * 1024 * 1024, durable()).await; + let recording = segments(3, Some(4)).await; + recording.stored().await; + let windowed = Replay::open(&recording.store, 64 * 1024 * 1024).await; assert!(matches!( windowed .rendition(Kind::Video, "1080p") @@ -717,11 +744,12 @@ async fn range_playlist_refuses_non_durable_or_windowed_timelines() { Some(Err(crate::Error::RangeUnavailable(_))) )); - let config = Config { - history: true, - ..Config::default() - }; - let live = Replay::open_with(&recording, 64 * 1024 * 1024, live(), config.clone()).await; + let mut recording = Recording::new(live(), Some(4)).await; + for segment in 0..3 { + add_segment(&mut recording, segment, 0); + } + recording.stored().await; + let live = Replay::open_with(&recording.store, 64 * 1024 * 1024, history()).await; assert!(matches!( live.rendition(Kind::Video, "1080p") .playlist(Some(range), None) @@ -732,177 +760,30 @@ async fn range_playlist_refuses_non_durable_or_windowed_timelines() { // A rendition without its own timeline would list only gaps, so it is not served at all. let mut archive = durable(); archive.timelines.remove("360p"); - let untimed = Replay::open_with(&recording, 64 * 1024 * 1024, archive, config).await; + let mut recording = Recording::new(archive, Some(4)).await; + for segment in 0..3 { + add_segment(&mut recording, segment, 0); + } + recording.stored().await; + let untimed = Replay::open_with(&recording.store, 64 * 1024 * 1024, history()).await; assert!(untimed.broadcaster.rendition(Kind::Video, "360p").is_none()); assert!(untimed.broadcaster.rendition(Kind::Video, "1080p").is_some()); } -#[tokio::test(start_paused = true)] -async fn history_mode_recovers_evicted_timeline_groups() { - history_after_expiry(false).await; -} - -#[tokio::test(start_paused = true)] -async fn history_mode_recovers_a_finished_timeline() { - history_after_expiry(true).await; -} - -async fn history_after_expiry(finished: bool) { - let recording = bounded_segments(12).await; - let expired = Expired::open(&recording, finished, &[]).await; - let replay = &expired.replay; - // Every rendition recovers its own evicted timeline, not only the reference: otherwise the - // reference's early rows resolve as gaps on its siblings. - for (kind, name) in [(Kind::Video, "1080p"), (Kind::Video, "360p"), (Kind::Audio, "audio")] { - let playlist = replay - .playlist_until(kind, name, |playlist| playlist.contains(".10.m4s\n")) - .await; - assert!(playlist.contains("#EXT-X-MEDIA-SEQUENCE:0\n"), "{name}: {playlist}"); - assert!(playlist.contains(".0.m4s\n"), "{name}: {playlist}"); - assert!(!playlist.contains("#EXT-X-GAP"), "{name}: {playlist}"); - assert!(replay.rendition(kind, name).listed_segment(0).await.unwrap().is_some()); - } -} - -/// How a stored timeline object is damaged. -#[derive(Clone, Copy, Debug)] -enum Fault { - /// The object is gone, so its group fails to FETCH. - Missing, - /// The object's frames are garbage, so its group fails to decode. - Corrupt, -} - -/// A history-mode export of a recording whose publisher cache expired first, so every timeline -/// group is FETCHed from the store. -struct Expired { - replay: Replay, - serving: tokio::task::JoinHandle<()>, - driver: tokio::task::JoinHandle, -} - -impl Expired { - /// Expire the cache, apply each `(track, record, fault)` to that record's stored timeline - /// object, then start exporting. `finished` supplies finality first. - async fn open(recording: &Recording, finished: bool, faults: &[(&str, u64, Fault)]) -> Self { - let (origin, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); - let driver = tokio::spawn(moq_net::time::run(driver)); - let broadcast = origin.create_broadcast("rec").unwrap(); - let track = broadcast - .create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info()) - .unwrap(); - let mut catalog = moq_json::snapshot::Producer::new(track, moq_json::snapshot::Config::default()); - catalog - .update(moq_net::Timed::from(&self::catalog(durable())).at(moq_net::Timestamp::now())) - .unwrap(); - let reader = moq_archive::Reader::open( - recording.store.clone(), - &broadcast, - reader::Config::new(recording.timelines()), - ) - .await - .unwrap(); - let serving = tokio::spawn(reader.serve()); - let reader = if finished { - reader.finish().unwrap(); - None - } else { - Some(reader) - }; - // Expire the publisher cache before any HLS consumer can retain a copy. - let now = moq_net::time::Instant::now(); - broadcast.info().pool.gc(now, &kio::Waiter::noop()); - broadcast - .info() - .pool - .gc(now + Duration::from_secs(31), &kio::Waiter::noop()); - let subscription = moq_net::track::Subscription::default() - .with_groups(0..) - .with_max_delay(Duration::from_millis((1_u64 << 62) - 1)); - let mut retained = broadcast - .consume() - .track(&timeline("1080p")) - .unwrap() - .subscribe(subscription) - .await - .unwrap() - .ordered(); - // Polled once, before the reader serves anything: a fill would answer group 0 from the - // archive, so only what the cache still holds is ready now. - let retained = match retained.poll_next_group(&kio::Waiter::noop()) { - std::task::Poll::Ready(group) => group.unwrap(), - std::task::Poll::Pending => None, - }; - assert!( - retained.is_none_or(|group| group.sequence > 0), - "the initial timeline groups must really be evicted" - ); - for &(track, record, fault) in faults { - let track = timeline(track); - let mut object = recording.store.get_segments(&track, record, true).await.unwrap(); - let key = moq_archive::Key::segments(&track, record).unwrap(); - recording.store.delete(&key).await.unwrap(); - if let Fault::Corrupt = fault { - for frame in object.groups.iter_mut().flat_map(|group| group.frames.iter_mut()) { - frame.payload = Bytes::from_static(b"garbage"); - } - recording.store.put_segments(&track, record, &object).await.unwrap(); - } - } - broadcast.announce(Default::default()).unwrap(); - let broadcaster = Broadcaster::new( - crate::resolved(&origin, "rec").await, - Config { - history: true, - ..Config::default() - }, - ); - tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()) - .await - .expect("a rendition becomes playable or fails after cache expiry"); - let replay = Replay { - reader, - broadcaster, - _catalog: catalog, - _broadcast: broadcast, - _origin: origin, - }; - Self { - replay, - serving, - driver, - } - } -} - -impl Drop for Expired { - fn drop(&mut self) { - self.serving.abort(); - self.driver.abort(); - } -} - /// Retention prunes a recording's first timeline objects, so history mode refuses it before /// listing anything rather than starting from a later checkpoint. #[tokio::test(start_paused = true)] async fn history_mode_refuses_a_retained_recording() { - let recording = bounded_segments(12).await; - for track in recording.timelines().values() { - for segment in 0..4 { - let key = moq_archive::Key::segments(track, segment).unwrap(); - recording.store.delete(&key).await.unwrap(); + let recording = segments(12, Some(4)).await; + recording.stored().await; + for track in ["360p", "1080p", "audio"] { + for (group, path) in objects(&recording.store, &timeline_name(track)).await { + if group < 4 { + recording.store.inner().delete(&path).await.unwrap(); + } } } - let replay = Replay::open_with( - &recording, - 64 * 1024 * 1024, - durable(), - Config { - history: true, - ..Config::default() - }, - ) - .await; + let replay = Replay::open_with(&recording.store, 64 * 1024 * 1024, history()).await; for (kind, name) in [(Kind::Video, "360p"), (Kind::Video, "1080p"), (Kind::Audio, "audio")] { let err = replay.failure(kind, name).await; assert!( @@ -911,13 +792,62 @@ async fn history_mode_refuses_a_retained_recording() { ); } - let broadcast = replay._origin.consume().request_broadcast("rec", None).await.unwrap(); - let err = history::read(&broadcast, &durable(), "1080p", |_| panic!("nothing is listed")) + let broadcast = replay.origin.consume().request_broadcast("rec", None).await.unwrap(); + let err = super::history::read(&broadcast, &durable(), "1080p", |_| panic!("nothing is listed")) .await .unwrap_err(); assert!(matches!(err, crate::Error::HistoryPruned(_)), "{err}"); } +/// How a stored timeline object is damaged. +#[derive(Clone, Copy, Debug)] +enum Fault { + /// The object is gone after it was listed, so its groups fail to FETCH. + Missing, + /// The object's bytes are garbage, so its groups fail to load. + Corrupt, +} + +/// A completed recording whose `track` timeline object holding group 6 is damaged by `fault` +/// once the consumer listed it, exported in history mode. +async fn damaged(track: &str, fault: Fault) -> Replay { + let recording = segments(12, Some(4)).await; + let store = recording.finish().await; + let (_, path) = objects(&store, &timeline_name(track)) + .await + .into_iter() + .rfind(|(group, _)| *group <= 6) + .unwrap(); + + let (origin, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); + let driver = tokio::spawn(moq_net::time::run(driver)); + let broadcast = origin.create_broadcast("rec").unwrap(); + let consumer = moq_archive::Consumer::open(store.clone(), broadcast.clone(), Default::default()) + .await + .unwrap(); + match fault { + Fault::Missing => store.inner().delete(&path).await.unwrap(), + Fault::Corrupt => store + .inner() + .put(&path, Bytes::from_static(b"garbage").into()) + .await + .map(|_| ()) + .unwrap(), + } + let consumer = tokio::spawn(consumer.run()); + broadcast.announce(Default::default()).unwrap(); + let broadcaster = Broadcaster::new(crate::resolved(&origin, "rec").await, history()); + tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()) + .await + .expect("a rendition becomes playable or fails"); + Replay { + broadcaster, + origin, + consumer, + driver, + } +} + #[tokio::test(start_paused = true)] async fn a_missing_mid_history_reference_group_fails_every_listing() { mid_history_reference_fault(Fault::Missing).await; @@ -932,9 +862,7 @@ async fn a_corrupt_mid_history_reference_group_fails_every_listing() { /// and end with `EXT-X-ENDLIST`, so every listing answers the failure instead, a clip of the /// readable prefix included. async fn mid_history_reference_fault(fault: Fault) { - let recording = bounded_segments(12).await; - let expired = Expired::open(&recording, true, &[("1080p", 6, fault)]).await; - let replay = &expired.replay; + let replay = damaged("1080p", fault).await; for (kind, name) in [(Kind::Video, "1080p"), (Kind::Video, "360p"), (Kind::Audio, "audio")] { replay.failure(kind, name).await; } @@ -953,9 +881,7 @@ async fn mid_history_reference_fault(fault: Fault) { /// manifest and master playlist leave it out. #[tokio::test(start_paused = true)] async fn a_corrupt_mid_history_rendition_group_fails_only_its_playlist() { - let recording = bounded_segments(12).await; - let expired = Expired::open(&recording, true, &[("360p", 6, Fault::Corrupt)]).await; - let replay = &expired.replay; + let replay = damaged("360p", Fault::Corrupt).await; replay.failure(Kind::Video, "360p").await; for (kind, name) in [(Kind::Video, "1080p"), (Kind::Audio, "audio")] { let playlist = replay diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 8f846c51f08..cfdb08dff87 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -5,7 +5,7 @@ //! pure function of the timeline records, and media bytes move only when an HTTP client requests //! a segment, which FETCHes exactly the frames that segment covers from the relay cache and //! transmuxes them to CMAF. A broadcast whose catalog advertises no timelines can't be served this -//! way and is skipped, and a rendition is served only once its own timeline is advertised. A `moq-archive` recording replayed onto a broadcast is served the same way: +//! way and is skipped, and a rendition is served only once its own timeline is advertised. A `moq-archive` recording served by its `Consumer` is served the same way: //! its playlists read only the stored timelines, and a segment GETs only its rendition's objects. //! //! Segment boundaries come from one reference rendition's records, numbered by record sequence, From ba5ffbcf2e197fbc32df7452cdc81e357777349f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 16:05:58 -0700 Subject: [PATCH 22/48] fix(net): build moq-net's tests without the async feature Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher/tracks.rs | 4 +++- rs/moq-net/src/model/track.rs | 3 +++ rs/moq-net/src/model/track/fill.rs | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher/tracks.rs b/rs/moq-net/src/ietf/publisher/tracks.rs index 296edc5e237..9721636dd10 100644 --- a/rs/moq-net/src/ietf/publisher/tracks.rs +++ b/rs/moq-net/src/ietf/publisher/tracks.rs @@ -1186,7 +1186,9 @@ where // What an update changed while forwarding outlives a pause. let applied = serve.track.subscription(); params.priority = crate::ietf::priority::to_wire(applied.priority); - params.max_age = applied.max_delay.and_then(|max_delay| max_delay.as_millis().try_into().ok()); + params.max_age = applied + .max_delay + .and_then(|max_delay| max_delay.as_millis().try_into().ok()); let served = match served { Ok(Some(Served::Paused)) => Served::Paused, Ok(Some(served)) => { diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index bf0e3b77e43..87ee02afd58 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15802,6 +15802,7 @@ mod test { } /// A feed declared to start at group 2, with groups 2 and 3 written, read from group 0. + #[cfg(feature = "async")] fn started_late() -> (Producer, Dynamic, Subscriber) { let mut producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); @@ -15812,6 +15813,7 @@ mod test { (producer, dynamic, subscriber) } + #[cfg(feature = "async")] fn write_filled(group: Result, millis: u64) { let mut group = group.unwrap(); group @@ -15820,6 +15822,7 @@ mod test { group.finish().unwrap(); } + #[cfg(feature = "async")] fn drained_drops(subscriber: &mut Subscriber) -> Vec<(u64, u64)> { let mut dropped = Vec::new(); while let Poll::Ready((range, _)) = subscriber.poll_drop(&kio::Waiter::noop()) { diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index d831e86bdc2..0289a707d7e 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -200,7 +200,7 @@ impl FillState { } /// Whether no fill is pending, queued or taken. - #[cfg(test)] + #[cfg(all(test, feature = "async"))] pub(super) fn is_empty(&self) -> bool { self.pending.is_empty() && self.queue.is_empty() && self.covers.is_empty() } From a0981052fafe2e4a242104d04b419b92ea96d4b8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 16:18:19 -0700 Subject: [PATCH 23/48] fix(bindings): saturate a peer's max delay instead of wrapping negative A fill's or subscription's delay comes from a peer and can pass Go's ~292-year time.Duration, or 2^63 microseconds in Kotlin and Dart, which read it back negative and so already expired. Go's conversion now saturates for every duration it lifts; Kotlin and Dart saturate their max delay views. Co-Authored-By: Claude Opus 5.5 --- dart/moq/lib/src/durations.dart | 8 ++++-- go/wrapper/records.go | 6 +++++ go/wrapper/records_internal_test.go | 27 +++++++++++++++++++ .../kotlin/dev/moq/Durations.kt | 7 +++-- 4 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 go/wrapper/records_internal_test.go diff --git a/dart/moq/lib/src/durations.dart b/dart/moq/lib/src/durations.dart index fc79c100e60..4f08fc96a88 100644 --- a/dart/moq/lib/src/durations.dart +++ b/dart/moq/lib/src/durations.dart @@ -38,7 +38,7 @@ extension SubscriptionDuration on MoqSubscription { /// Upper bound on buffering before a stalled group is skipped, or null for the /// publisher's max age alone. Duration? get maxDelay => - maxDelayUs == null ? null : Duration(microseconds: maxDelayUs!); + maxDelayUs == null ? null : _saturatingMicros(maxDelayUs!); } /// Duration views over a fill. @@ -46,10 +46,14 @@ extension FillDuration on MoqFill { /// The most tolerant age budget among its readers, or null for the publisher's max age alone. Duration? get maxDelay { final us = maxDelayUs(); - return us == null ? null : Duration(microseconds: us); + return us == null ? null : _saturatingMicros(us); } } +/// A u64 past 2^63 - 1 microseconds, such as a peer's delay, reads back negative. +Duration _saturatingMicros(int us) => + Duration(microseconds: us < 0 ? 0x7FFFFFFFFFFFFFFF : us); + /// Duration views over the publisher-side track settings. extension TrackInfoDuration on MoqTrackInfo { /// Maximum age of a non-latest group before the publisher evicts it, or null for the default. diff --git a/go/wrapper/records.go b/go/wrapper/records.go index 2247ced8511..ee629c07743 100644 --- a/go/wrapper/records.go +++ b/go/wrapper/records.go @@ -1,6 +1,7 @@ package moq import ( + "math" "time" ffi "moq.dev/moq-ffi/moq" @@ -11,7 +12,12 @@ import ( // target language has a duration type. These records mirror the generated ones // with time.Duration fields and convert at the boundary. +// fromMicros saturates past time.Duration's ~292 years, which a peer's delay or +// max age can exceed, rather than wrapping to a negative duration. func fromMicros(us uint64) time.Duration { + if us > uint64(math.MaxInt64/int64(time.Microsecond)) { + return time.Duration(math.MaxInt64) + } return time.Duration(us) * time.Microsecond } diff --git a/go/wrapper/records_internal_test.go b/go/wrapper/records_internal_test.go new file mode 100644 index 00000000000..ed4162a166d --- /dev/null +++ b/go/wrapper/records_internal_test.go @@ -0,0 +1,27 @@ +package moq + +import ( + "math" + "testing" + "time" +) + +// A peer's delay or max age can pass time.Duration's ~292 years, which must read as +// the longest duration rather than wrap to a negative one that is already expired. +func TestFromMicrosSaturates(t *testing.T) { + limit := uint64(math.MaxInt64 / int64(time.Microsecond)) + cases := []struct { + us uint64 + want time.Duration + }{ + {us: 1, want: time.Microsecond}, + {us: limit, want: time.Duration(limit) * time.Microsecond}, + {us: limit + 1, want: time.Duration(math.MaxInt64)}, + {us: math.MaxUint64, want: time.Duration(math.MaxInt64)}, + } + for _, c := range cases { + if got := fromMicros(c.us); got != c.want { + t.Errorf("fromMicros(%d) = %v, want %v", c.us, got, c.want) + } + } +} diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt index 67d5cfe4237..72a2df33db1 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt @@ -55,11 +55,14 @@ val QuicConfig.keepAlive: Duration? /** Upper bound on buffering before a stalled group is skipped, or null for the publisher's max age alone. */ val Subscription.maxDelay: Duration? - get() = maxDelayUs?.toLong()?.microseconds + get() = maxDelayUs?.let(::saturatingMicros) /** The most tolerant age budget among a fill's readers, or null for the publisher's max age alone. */ val Fill.maxDelay: Duration? - get() = maxDelayUs()?.toLong()?.microseconds + get() = maxDelayUs()?.let(::saturatingMicros) + +// A peer's delay can pass Long.MAX_VALUE microseconds, which toLong() would wrap negative. +private fun saturatingMicros(us: ULong): Duration = minOf(us, Long.MAX_VALUE.toULong()).toLong().microseconds /** Maximum age of a non-latest group before the publisher evicts it, or null for the default. */ val TrackInfo.maxAge: Duration? From 738ba580525c08f80ef7a3f8e1c9037884eaadf4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 16:58:39 -0700 Subject: [PATCH 24/48] fix(net): a narrowed reader leaves the rest of its fill A fill now counts how many joins still read each sequence. A reader narrowing its ranges releases what it dropped, so Fill::next walks only what some reader still reads, and dropping the fill names only those as dropped. Also adds the cpp/moq MIGRATE entry for fills. Co-Authored-By: Claude Opus 5.5 --- cpp/moq/MIGRATE.md | 10 ++- rs/moq-net/src/model/track.rs | 35 ++++++++++ rs/moq-net/src/model/track/fill.rs | 105 +++++++++++++++++++++++++++-- rs/moq-net/src/model/track/miss.rs | 11 ++- 4 files changed, 150 insertions(+), 11 deletions(-) diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index 00ff7db554b..7025e3c372d 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -4,9 +4,17 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`TrackDynamic::requested_group()` is now `requested_fill()`, returning a `Fill` (`start_group()`, `start_frame()`, `end_group()`, `priority()`, `max_delay_us()`, `order()`, `next()`, `accept(sequence)`, `drop_groups(start, end)`, `is_used()`, `unused()`). +`GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `is_used()` or `unused()` on the fill. +A refusal carries no application code: `drop_groups` fails the waiting fetches as not found. +A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. +`Subscription::max_delay_us` is a `std::optional` that still defaults to 0, where `std::nullopt` leaves the publisher's max age alone to bound delivery. + ### Priorities are signed (#6142) -`TrackInfo::priority`, `Subscription::priority`, and `FetchGroupOptions::priority` are `int8_t`, and `GroupRequest::priority()` returns one. +`TrackInfo::priority`, `Subscription::priority`, and `FetchGroupOptions::priority` are `int8_t`, and `Fill::priority()` returns one. Higher is first and 0 is the unset midpoint; `TrackInfo::priority` defaulted to 127 and now defaults to 0. To keep a value's moq-lite byte, subtract 128 from it, widening first (`static_cast(int(old) - 128)`): 255 becomes 127, 128 becomes 0, and 0 becomes -128. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 5d768a59a00..ff4a57ec483 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15995,6 +15995,41 @@ mod test { } } + /// A reader narrowing its ranges leaves the part of its fill it no longer reads: the + /// handler is not asked for it, and dropping the fill names none of it dropped. + #[test] + #[cfg(feature = "async")] + fn a_narrowed_reader_leaves_the_rest_of_its_fill() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!(fill.next(), Some(0)); + + subscriber.update(ascending(8..)).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert_eq!(fill.next(), Some(8), "nobody reads below 8 any more"); + write_filled(fill.accept(8, None), 8); + assert_eq!(fill.next(), Some(9)); + // Over, so what the dropped fill left is a drop rather than a group still to come. + producer.finish().unwrap(); + drop(fill); + + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the filled group is read"); + }; + assert_eq!(group.sequence, 8); + assert_eq!(drained_drops(&mut subscriber), [(9, 10)], "only what it still read"); + assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); + } + /// A group the handler refused that still arrives live is delivered. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 0289a707d7e..128c90be2c7 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -41,11 +41,51 @@ struct Pending { max_delay: Option, order: Order, queued: bool, + // How many joins still read each sequence: one a reader narrowed away is no longer owed. + wants: RangeMap, // Readers hold consumers, so it counts the fill's demand; the handler's copy says what // became of each sequence. result: kio::Producer, } +impl Pending { + /// Count one more join reading `groups`. + fn want(&mut self, groups: ops::Range) { + let mut counts: Vec<(ops::Range, u32)> = self + .wants + .overlapping(&groups) + .map(|(run, count)| (run.start.max(groups.start)..run.end.min(groups.end), count + 1)) + .collect(); + counts.extend(self.wants.gaps(&groups).map(|gap| (gap, 1))); + for (run, count) in counts { + self.wants.insert(run, count); + } + } + + /// Count one join out of reading `groups`, which it counted in. + fn unwant(&mut self, groups: ops::Range) { + let counts: Vec<(ops::Range, u32)> = self + .wants + .overlapping(&groups) + .map(|(run, count)| (run.start.max(groups.start)..run.end.min(groups.end), count - 1)) + .collect(); + for (run, count) in counts { + match count { + 0 => self.wants.remove(run), + count => self.wants.insert(run, count), + } + } + } + + /// The runs of `span` a join still reads. + fn wanted(&self, span: &ops::Range) -> RangeSet { + self.wants + .overlapping(span) + .map(|(run, _)| run.start.max(span.start)..run.end.min(span.end)) + .collect() + } +} + /// What became of a fill's sequences, shared with every reader that joined it. #[derive(Default)] // pending: /quest/m1/public-model/deferred.md @@ -144,6 +184,8 @@ pub(super) struct Join { id: u64, /// The sequences of the fill this reader asked for. pub groups: ops::Range, + // The part of `groups` it still reads, counted in the fill's wants. + held: RangeSet, // Counts this reader in the fill's demand; `None` only while dropping. outcome: Option>, attempt: kio::ProducerWeak, @@ -162,7 +204,13 @@ impl Drop for Join { // its handler is about to drop. let mut fills = self.fills.lock(); drop(self.outcome.take()); - if !self.attempt.is_used() && fills.pending.get(&self.id).is_some_and(|pending| self.is_ours(pending)) { + let Some(pending) = fills.pending.get_mut(&self.id).filter(|pending| self.is_ours(pending)) else { + return; + }; + for run in std::mem::take(&mut self.held).iter() { + pending.unwant(run.clone()); + } + if !self.attempt.is_used() { fills.withdraw(self.id); } } @@ -172,6 +220,31 @@ impl Join { fn is_ours(&self, pending: &Pending) -> bool { pending.result.weak().same_channel(&self.attempt) } + + /// Stop reading `lost`, so the fill no longer owes this reader any of it. + pub(super) fn release(&mut self, lost: &RangeSet) { + let released: Vec> = lost + .overlapping(&self.groups) + .flat_map(|run| { + self.held + .overlapping(run) + .map(move |held| held.start.max(run.start)..held.end.min(run.end)) + }) + .collect(); + if released.is_empty() { + return; + } + let mut fills = self.fills.lock(); + let pending = fills.pending.get_mut(&self.id).filter(|pending| self.is_ours(pending)); + for run in &released { + self.held.remove(run.clone()); + } + if let Some(pending) = pending { + for run in released { + pending.unwant(run); + } + } + } } impl FillState { @@ -242,9 +315,11 @@ impl FillState { pending.start.frame = pending.start.frame.min(frame); } } + pending.want(groups.clone()); joins.push(Join { fills: shared.clone(), id, + held: RangeSet::from_iter([groups.clone()]), groups, outcome: Some(pending.result.consume()), attempt: pending.result.weak(), @@ -266,6 +341,7 @@ impl FillState { fills: shared.clone(), id, groups: groups.clone(), + held: RangeSet::from_iter([groups.clone()]), outcome: Some(result.consume()), attempt: result.weak(), }); @@ -279,6 +355,7 @@ impl FillState { max_delay: ask.max_delay, order: ask.order, queued: true, + wants: RangeMap::from_iter([(groups.clone(), 1)]), result, }, ); @@ -484,14 +561,14 @@ impl Fill { self.inner.result.poll_unused(waiter).map(|_| ()) } - /// The next group still owed in the fill's order: neither answered nor delivered live - /// since it was asked. `None` once every group is answered. + /// The next group still owed in the fill's order: a reader still reads it, and it was + /// neither answered nor delivered live since it was asked. `None` once none is owed. pub fn next(&self) -> Option { loop { - let span = self.inner.start.group..self.inner.end; + let wanted = self.inner.wanted(); let sequence = { let outcome = self.inner.result.read(); - let mut gaps = outcome.resolved.gaps(&span); + let mut gaps = wanted.iter().flat_map(|run| outcome.resolved.gaps(run)); match self.inner.order { Order::Ascending => gaps.next().map(|gap| gap.start), Order::Descending => gaps.last().map(|gap| gap.end - 1), @@ -545,6 +622,16 @@ impl Inner { (self.start.group..self.end).contains(&sequence) } + /// The runs of the fill a reader still reads, read before any other lock. + fn wanted(&self) -> RangeSet { + let span = self.start.group..self.end; + let fills = self.fills.lock(); + fills + .pending + .get(&self.id) + .map_or_else(RangeSet::new, |pending| pending.wanted(&span)) + } + fn frame_start(&self, sequence: u64) -> u64 { match sequence == self.start.group { true => self.start.frame, @@ -655,9 +742,13 @@ impl Inner { impl Drop for Inner { fn drop(&mut self) { // Whatever the handler left unanswered is named dropped, but only while a reader still - // waits: once demand left, the fill was withdrawn and records nothing. A group cached + // reads it: once demand left, the fill was withdrawn and records nothing. A group cached // since (the live feed delivered it) is no drop at all. - let left = self.unresolved(self.start.group..self.end); + let wanted = self.wanted(); + let left: RangeSet = { + let outcome = self.result.read(); + wanted.iter().flat_map(|run| outcome.resolved.gaps(run)).collect() + }; if !left.is_empty() && self.result.is_used() { if let Ok(mut state) = TrackState::modify(&self.state) { // Unanswered is a miss downstream, as an explicit `drop_groups` would say. diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 0b47b17880a..a753410eb4d 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -118,8 +118,9 @@ impl Misses { self.filled.remove(&sequence); } - /// Forget what the reader no longer reads: below `from`, or outside `admitted`. A fill - /// nobody waits on is withdrawn once its last join goes. + /// Forget what the reader no longer reads: below `from`, or outside `admitted`. Its fills + /// no longer owe it those groups, and one nobody waits on is withdrawn once its last join + /// goes. pub fn retain(&mut self, from: u64, admitted: &RangeInclusiveSet) { if self.wanted.is_empty() && self.filled.is_empty() { return; @@ -132,9 +133,13 @@ impl Misses { kept.insert(inside); } } + let lost: RangeSet = self.wanted.iter().flat_map(|run| kept.gaps(run)).collect(); self.wanted = kept; let wanted = &self.wanted; - self.joins.retain(|join| wanted.overlaps(&join.groups)); + self.joins.retain_mut(|join| { + join.release(&lost); + wanted.overlaps(&join.groups) + }); } /// Take in what the joined fills answered, parking `waiter` on the rest. Returns the From 1d3aced538cde6a93e1efe1018c47b071c0b024c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 17:18:59 -0700 Subject: [PATCH 25/48] fix(net): a fill owes only what its readers still read A group fetch is cancelled once no reader reads its group, and the sequence stops routing to that fill, so a later reader asks afresh rather than joining a cancelled fetch. drop_groups, including the Old refusal of older groups, records only groups a reader still reads. A descending reader asks for its disjoint misses newest first. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 60 ++++++++++++++++++++- rs/moq-net/src/model/track/fill.rs | 85 ++++++++++++++++++++++++------ rs/moq-net/src/model/track/miss.rs | 9 +++- 3 files changed, 133 insertions(+), 21 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index ff4a57ec483..2ac53b4e5d1 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -15996,7 +15996,8 @@ mod test { } /// A reader narrowing its ranges leaves the part of its fill it no longer reads: the - /// handler is not asked for it, and dropping the fill names none of it dropped. + /// handler is not asked for it, a fetch of it in flight is cancelled, and neither a bulk + /// refusal nor dropping the fill names it dropped. #[test] #[cfg(feature = "async")] fn a_narrowed_reader_leaves_the_rest_of_its_fill() { @@ -16012,14 +16013,20 @@ mod test { panic!("below the start is a miss"); }; assert_eq!(fill.next(), Some(0)); + let fetching = fill.group(0); + assert!(fetching.poll_unused(&waiter).is_pending()); subscriber.update(ascending(8..)).unwrap(); assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(fetching.poll_unused(&waiter).is_ready(), "nobody reads group 0 now"); + drop(fetching); + assert!(fill.group(8).poll_unused(&waiter).is_pending()); assert_eq!(fill.next(), Some(8), "nobody reads below 8 any more"); write_filled(fill.accept(8, None), 8); assert_eq!(fill.next(), Some(9)); - // Over, so what the dropped fill left is a drop rather than a group still to come. + // Over, so what the fill refuses is a drop rather than a group still to come. producer.finish().unwrap(); + fill.drop_groups(0..9); drop(fill); let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { @@ -16030,6 +16037,55 @@ mod test { assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); } + /// What a fill stopped owing is asked afresh by a reader that wants it later, rather than + /// joining a fetch its handler is cancelling. + #[test] + #[cfg(feature = "async")] + fn a_later_reader_asks_afresh_for_what_a_fill_stopped_owing() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); + let mut narrowed = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = narrowed.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + narrowed.update(ascending(8..)).unwrap(); + assert!(narrowed.poll_recv_group(&waiter).is_pending()); + + let mut later = producer.subscribe(ascending(0..)); + while let Poll::Ready(Ok(Some(_))) = later.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(again)) = dynamic.poll_requested_fill(&waiter) else { + panic!("what the first fill stopped owing is asked afresh"); + }; + assert_eq!((again.start().group, again.end()), (0, 8)); + assert_eq!(fill.next(), Some(8), "the first fill still owes only 8 and up"); + assert!(fill.group(0).poll_unused(&waiter).is_ready()); + } + + /// A descending reader's disjoint misses queue newest first, so a handler serving one fill + /// at a time reaches the newer history first. + #[test] + #[cfg(feature = "async")] + fn a_descending_reader_asks_its_newest_miss_first() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = + producer.subscribe(replay().with_ranges([Range::groups(0..2), Range::groups(4..6), Range::groups(8..)])); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) { + asked.push((fill.start().group, fill.end())); + } + assert_eq!(asked, [(4, 6), (0, 2)]); + } + /// A group the handler refused that still arrives live is delivered. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 128c90be2c7..c92fa498681 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -62,19 +62,25 @@ impl Pending { } } - /// Count one join out of reading `groups`, which it counted in. - fn unwant(&mut self, groups: ops::Range) { + /// Count one join out of reading `groups`, which it counted in. Returns the runs nobody + /// reads any more. + fn unwant(&mut self, groups: ops::Range) -> Vec> { let counts: Vec<(ops::Range, u32)> = self .wants .overlapping(&groups) .map(|(run, count)| (run.start.max(groups.start)..run.end.min(groups.end), count - 1)) .collect(); + let mut unwanted = Vec::new(); for (run, count) in counts { match count { - 0 => self.wants.remove(run), + 0 => { + self.wants.remove(run.clone()); + unwanted.push(run); + } count => self.wants.insert(run, count), } } + unwanted } /// The runs of `span` a join still reads. @@ -204,11 +210,11 @@ impl Drop for Join { // its handler is about to drop. let mut fills = self.fills.lock(); drop(self.outcome.take()); - let Some(pending) = fills.pending.get_mut(&self.id).filter(|pending| self.is_ours(pending)) else { + if !fills.pending.get(&self.id).is_some_and(|pending| self.is_ours(pending)) { return; - }; + } for run in std::mem::take(&mut self.held).iter() { - pending.unwant(run.clone()); + fills.unwant(self.id, run.clone()); } if !self.attempt.is_used() { fills.withdraw(self.id); @@ -234,15 +240,15 @@ impl Join { if released.is_empty() { return; } - let mut fills = self.fills.lock(); - let pending = fills.pending.get_mut(&self.id).filter(|pending| self.is_ours(pending)); for run in &released { self.held.remove(run.clone()); } - if let Some(pending) = pending { - for run in released { - pending.unwant(run); - } + let mut fills = self.fills.lock(); + if !fills.pending.get(&self.id).is_some_and(|pending| self.is_ours(pending)) { + return; + } + for run in released { + fills.unwant(self.id, run); } } } @@ -364,6 +370,20 @@ impl FillState { (joins, unfillable) } + /// Count a join out of reading `groups` of fill `id`. What nobody reads any more is no + /// longer routed to it: a reader wanting it later asks afresh rather than joining a fetch + /// being cancelled, so a sequence a fill stops owing stays unowed. + fn unwant(&mut self, id: u64, groups: ops::Range) { + let Some(pending) = self.pending.get_mut(&id) else { + return; + }; + for run in pending.unwant(groups) { + for range in self.routed(id, &run) { + self.covers.remove(range); + } + } + } + /// Take the oldest queued fill, still pending (joinable) until it ends. fn pop(&mut self) -> Option { let id = self.queue.pop_front()?; @@ -540,7 +560,8 @@ impl Fill { } /// Name `groups` as never to be delivered: the source no longer has them. Readers stop - /// waiting for them, and the track records them so nobody asks again. + /// waiting for them, and the track records them so nobody asks again. Only the groups a + /// reader still reads count; the rest of the fill was never owed. pub fn drop_groups(&self, groups: ops::Range) { self.inner.drop_groups(groups, Error::NotFound); } @@ -660,13 +681,27 @@ impl Inner { } /// Refuse `groups` with `err`, recording them in the track's drops when the source no - /// longer has them, which binds every reader. + /// longer has them, which binds every reader. Only what a reader still reads: a handler + /// refusing the whole fill never checked the rest. fn drop_groups(&self, groups: ops::Range, err: Error) { let groups = groups.start.max(self.start.group)..groups.end.min(self.end); if groups.is_empty() { return; } - let unresolved = self.unresolved(groups); + let wanted = self.wanted(); + let unresolved: RangeSet = { + let outcome = self.result.read(); + let mut unresolved = RangeSet::new(); + for run in wanted.overlapping(&groups) { + for gap in outcome + .resolved + .gaps(&(run.start.max(groups.start)..run.end.min(groups.end))) + { + unresolved.insert(gap); + } + } + unresolved + }; if unresolved.is_empty() { return; } @@ -823,10 +858,26 @@ impl GroupFill { self.frame_start } - /// Poll until no reader waits on the fill. + /// Poll until no reader waits on this group, as when every reader narrowed past it. // private: capability gap pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { - self.inner.result.poll_unused(waiter).map(|_| ()) + if self.inner.result.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + let (id, sequence) = (self.inner.id, self.sequence); + self.inner + .fills + .poll(waiter, |fills| { + match fills + .pending + .get(&id) + .is_some_and(|pending| pending.wants.contains_key(&sequence)) + { + true => Poll::Pending, + false => Poll::Ready(()), + } + }) + .map(drop) } /// Poll until the track closes, with its abort error. diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index a753410eb4d..f15ec56559a 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -348,7 +348,8 @@ impl Misses { asks } - /// Ask the fills for `runs`, settling what no handler can take as refused. + /// Ask the fills for `runs` in the reader's order, settling what no handler can take as + /// refused. Fills queue in the order asked, so a descending reader's newest run comes first. pub fn ask( &mut self, state: &kio::Consumer, @@ -357,7 +358,11 @@ impl Misses { asking: &Asking, given_up: &mut GivenUp, ) { - for run in runs.iter() { + let mut runs: Vec> = runs.iter().cloned().collect(); + if asking.order == Order::Descending { + runs.reverse(); + } + for run in runs { let frame = asking .start .filter(|start| start.group == run.start) From 1674f3845e457730470a7bbc644aea71ef1e3eba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 17:36:39 -0700 Subject: [PATCH 26/48] fix(net): a held feed fetch replaces an abandoned one under the limit The limit counted the attempt a replacement takes over, so a pre-lite-05 track at its limit refused the replacement with TooManyRequests. Release what nobody waits on before counting. A held fill whose readers left the replaced group moves on to the next group it owes rather than being dropped, which would name those groups dropped. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 81 +++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index c0b4e4e3588..90798d716f9 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2512,6 +2512,37 @@ mod tests { assert!(next.now_or_never().unwrap().is_ok()); } + /// A replacement takes the place of the attempt it replaces under the limit, before any + /// poll releases that one. + #[moq_clock::test] + async fn a_feed_fetch_replaces_an_abandoned_attempt_at_the_limit() { + let start = crate::time::Instant::now(); + let clock = crate::time::Deadlines::new(start); + let tail = kio::Producer::new(Tail::default()); + let sub = feed_sub(&tail).await; + let (track, mut callers, requests) = feed_batch([1, 2]); + let dynamic = track.dynamic(); + let mut fetches = FeedFetches::new(&clock); + fetches.max = 2; + for req in requests { + fetches.push(req, &track.consume()); + } + fetches.poll(&sub, start, &kio::Waiter::noop()); + drop(callers.remove(0)); + let mut next = track.consume().fetch_group(1, None); + let req = dynamic + .requested_fill() + .now_or_never() + .expect("replacement queued") + .unwrap(); + fetches.push(req, &track.consume()); + assert!((&mut next).now_or_never().is_none(), "held, not refused"); + assert_eq!(fetches.held.len(), 2); + arrive(&track, &tail, 1, start); + fetches.poll(&sub, start, &kio::Waiter::noop()); + assert!(next.now_or_never().unwrap().is_ok()); + } + /// Sweep held fetches, unrelated tracks, and live arrival runs independently. #[moq_clock::test] #[ignore = "release benchmark, run by nightly"] @@ -7023,29 +7054,41 @@ impl FeedFetches { } else { self.arrivals = Some(arrivals); } - if self.held.len() >= self.max { - fill.refuse(Error::TooManyRequests); - return; - } self.hold(fill, true); } - /// Wait on the next group `fill` still owes, if any. + /// Wait on the next group `fill` still owes, if any. A fresh fill counts against the + /// limit, once what nobody waits on any more is released. fn hold(&mut self, fill: track::Fill, fresh: bool) { - let Some(sequence) = fill.next() else { - return; - }; - // The last caller may leave and a replacement attempt arrive before the - // queued cancellation gets polled. The old attempt owns no callers now. - if let Some(previous) = self.held.remove(&sequence) { - debug_assert!(previous.fill.poll_unused(&kio::Waiter::noop()).is_ready()); - } - let waiter = kio::Waiter::new(std::task::Waker::from(Arc::new(FetchWake { - sequence, - ready: self.ready.clone(), - }))); - self.held.insert(sequence, HeldFetch { fill, waiter, fresh }); - self.ready.lock().insert(sequence); + let mut next = Some((fill, fresh)); + while let Some((fill, fresh)) = next.take() { + let Some(sequence) = fill.next() else { + continue; + }; + // A replacement attempt may arrive before the poll that would see the held one's + // readers leave this group: that one moves on to the next group it owes, if any. + if let Some(previous) = self.held.remove(&sequence) + && previous.fill.is_used() + { + next = Some((previous.fill, false)); + } + if fresh { + let moving = usize::from(next.is_some()); + if self.held.len() + moving >= self.max { + self.held.retain(|_, held| held.fill.is_used()); + } + if self.held.len() + moving >= self.max { + fill.refuse(Error::TooManyRequests); + continue; + } + } + let waiter = kio::Waiter::new(std::task::Waker::from(Arc::new(FetchWake { + sequence, + ready: self.ready.clone(), + }))); + self.held.insert(sequence, HeldFetch { fill, waiter, fresh }); + self.ready.lock().insert(sequence); + } } fn poll( From c9a7d7d1beb20382b69f520b8d6c8a0092cb7a8b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 18:43:03 -0700 Subject: [PATCH 27/48] feat(net): fill handlers watch demand for a single group Fill::is_wanted, unwanted, and poll_unwanted tell a handler when no reader reads one group any more, as when every reader narrowed past it while the fill stays used. moq-transcode's spawn_fill and the archive's group serving now abandon such a group before accepting it, releasing a transcode permit and moving on to the groups the fill still owes. moq-c withdraws the matching group request, so answering it fails as not found, and hands out the next one. Mirrored in moq-ffi, Go, Swift, Python, and the regenerated Dart bindings. Co-Authored-By: Claude Opus 5.5 --- cpp/moq/MIGRATE.md | 1 + dart/moq/MIGRATE.md | 1 + dart/moq_ffi/lib/src/moq.dart | 57 +++++++++++++++++++++++- doc/concept/moq-lite.md | 3 +- go/wrapper/MIGRATE.md | 1 + go/wrapper/moq_test.go | 3 ++ go/wrapper/publish.go | 14 +++++- kt/moq/MIGRATE.md | 1 + py/moq-rs/MIGRATE.md | 1 + py/moq-rs/moq/publish.py | 14 +++++- py/moq-rs/tests/test_local.py | 2 + rs/moq-archive/src/reader/mod.rs | 26 ++++++++--- rs/moq-archive/src/reader/tests.rs | 69 ++++++++++++++++++++++++++++++ rs/moq-c/MIGRATE.md | 1 + rs/moq-c/src/api.rs | 2 + rs/moq-c/src/publish.rs | 11 +++-- rs/moq-c/src/test.rs | 53 +++++++++++++++++++++++ rs/moq-ffi/MIGRATE.md | 1 + rs/moq-ffi/src/producer.rs | 19 +++++++- rs/moq-ffi/src/test.rs | 7 +++ rs/moq-net/MIGRATE.md | 1 + rs/moq-net/src/model/track.rs | 13 +++--- rs/moq-net/src/model/track/fill.rs | 62 +++++++++++++++++++-------- rs/moq-transcode/src/rung.rs | 62 ++++++++++++++++++++++++--- swift/MIGRATE.md | 1 + swift/Sources/Moq/Dynamic.swift | 12 ++++++ 26 files changed, 392 insertions(+), 46 deletions(-) diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index 7025e3c372d..1d58634a377 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic::requested_group()` is now `requested_fill()`, returning a `Fill` (`start_group()`, `start_frame()`, `end_group()`, `priority()`, `max_delay_us()`, `order()`, `next()`, `accept(sequence)`, `drop_groups(start, end)`, `is_used()`, `unused()`). `GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `is_used()` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `is_wanted(sequence)` and `unwanted(sequence)` say when to stop serving one. A refusal carries no application code: `drop_groups` fails the waiting fetches as not found. A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. `Subscription::max_delay_us` is a `std::optional` that still defaults to 0, where `std::nullopt` leaves the publisher's max age alone to bound delivery. diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 7dd0e80ffea..40e44d7e0f4 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup()`, `startFrame()`, `endGroup()`, `priority()`, `maxDelayUs()`, `order()`, `next()`, `accept(sequence)`, `dropGroups(start, end)`, `isUsed()`, `unused()`). `GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed()` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `isWanted(sequence)` and `unwanted(sequence)` say when to stop serving one. A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. `Subscription.maxDelayUs` and `maxDelay` are nullable, where null leaves the publisher's max age alone to bound delivery. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index ea0e01e2902..b3038a6a261 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,9 +8,7 @@ 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"; @@ -11467,6 +11465,7 @@ abstract class MoqFillInterface { void dropGroups({required int start, required int end}); int endGroup(); bool isUsed(); + bool isWanted({required int sequence}); int? maxDelayUs(); int? next(); MoqOrder order(); @@ -11474,6 +11473,7 @@ abstract class MoqFillInterface { int startFrame(); int startGroup(); Future unused(); + Future unwanted({required int sequence}); } final _MoqFillFinalizer = Finalizer>((ptr) { @@ -11550,6 +11550,18 @@ class MoqFill implements MoqFillInterface { ); } + bool isWanted({required int sequence}) { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_is_wanted( + uniffiClonePointer(), + FfiConverterUInt64.lower(sequence), + status, + ), + FfiConverterBool.lift, + null, + ); + } + int? maxDelayUs() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqfill_max_delay_us( @@ -11622,6 +11634,20 @@ class MoqFill implements MoqFillInterface { moqExceptionErrorHandler, ); } + + Future unwanted({required int sequence}) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqfill_unwanted( + uniffiClonePointer(), + FfiConverterUInt64.lower(sequence), + ), + ffi_moq_ffi_rust_future_poll_void, + ffi_moq_ffi_rust_future_complete_void, + ffi_moq_ffi_rust_future_free_void, + (_) {}, + moqExceptionErrorHandler, + ); + } } class FfiConverterMoqFill { @@ -17361,6 +17387,15 @@ external int uniffi_moq_ffi_fn_method_moqfill_is_used( Pointer uniffiStatus, ); +@Native, Uint64, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqfill_is_wanted( + Pointer ptr, + int sequence, + Pointer uniffiStatus, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -17414,6 +17449,12 @@ external Pointer uniffi_moq_ffi_fn_method_moqfill_unused( Pointer ptr, ); +@Native Function(Pointer, Uint64)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqfill_unwanted( + Pointer ptr, + int sequence, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -18924,6 +18965,9 @@ external int uniffi_moq_ffi_checksum_method_moqfill_end_group(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqfill_is_used(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_is_wanted(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqfill_max_delay_us(); @@ -18945,6 +18989,9 @@ external int uniffi_moq_ffi_checksum_method_moqfill_start_group(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqfill_unused(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_unwanted(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqgroupproducer_abort(); @@ -19639,6 +19686,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqfill_is_used() != 6429) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqfill_is_wanted() != 35921) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqfill_max_delay_us() != 40340) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -19660,6 +19710,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqfill_unused() != 3173) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqfill_unwanted() != 58929) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqgroupproducer_abort() != 59787) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 6bba8def9a7..b6c07549d91 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -254,7 +254,8 @@ the start, also serves the cached groups they newly cover, never one already served. A group they cover below the live feed that the cache lacks (evicted before the subscriber first looked for it, skipped, or below where the feed starts) is asked of the track's dynamic handler -as a fill, which a relay serves by fetching upstream one group at a time; with +as a fill, which a relay serves by fetching upstream one group at a time, skipping +a group once every subscriber that asked for it narrows past it; with no handler it is reported as dropped once the feed stops waiting for it. One evicted after the subscriber saw it cached, but before it read it, is skipped. A relay merges its subscribers' ranges into one upstream request, which today's wire versions carry as the single span covering them. A time anchor is diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index 24021ae1f2c..6d8454a20f1 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic.RequestedGroup` is now `RequestedFill`, returning a `*Fill` (`StartGroup`, `StartFrame`, `EndGroup`, `Priority`, `MaxDelay`, `Order`, `Next`, `Accept(sequence)`, `DropGroups(start, end)`, `IsUsed`, `Unused`), and `All` ranges over fills. `GroupRequest` and `GroupDemand` are removed: serve each group `Next` names, and watch `IsUsed` or `Unused` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `Next` skips it, and `IsWanted(sequence)` and `Unwanted(ctx, sequence)` say when to stop serving one. A refusal carries no application code: `DropGroups` fails the waiting fetches with `ErrNotFound`. ### `Subscription.MaxDelay` is a pointer diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index a2576725a63..2d23a4de7d1 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -596,6 +596,9 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { if next := fill.Next(); next == nil || *next != 7 || !fill.IsUsed() { t.Fatalf("unexpected fill: next=%v used=%v", next, fill.IsUsed()) } + if !fill.IsWanted(7) || fill.IsWanted(8) { + t.Fatalf("unexpected wants: 7=%v 8=%v", fill.IsWanted(7), fill.IsWanted(8)) + } produced, err := fill.Accept(7) if err != nil { t.Fatal(err) diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 63d2b96b493..363e8189a43 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -379,7 +379,8 @@ func (d *TrackDynamic) Cancel() { // Accept each group you have and DropGroups the rest; Next names what is still // owed, in the fill's order. Releasing the fill names whatever is left as dropped // while a reader still wants it; once nobody does, it is withdrawn, so stop -// serving it when Unused returns. +// serving it when Unused returns. Stop serving a single group when Unwanted +// returns for it, and move on to Next. type Fill struct { inner *ffi.MoqFill } @@ -444,3 +445,14 @@ func (f *Fill) IsUsed() bool { func (f *Fill) Unused(ctx context.Context) error { return f.inner.Unused(ctx) } + +// IsWanted reports whether a reader still reads group sequence: false once every +// reader narrowed past it, or nobody waits on the fill. +func (f *Fill) IsWanted(sequence uint64) bool { + return f.inner.IsWanted(sequence) +} + +// Unwanted blocks until no reader reads group sequence, so stop serving it. +func (f *Fill) Unwanted(ctx context.Context, sequence uint64) error { + return f.inner.Unwanted(ctx, sequence) +} diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 273fc1e6738..7e344de861c 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup()`, `startFrame()`, `endGroup()`, `priority()`, `maxDelayUs()`, `order()`, `next()`, `accept(sequence)`, `dropGroups(start, end)`, `isUsed()`, `unused()`), and `requestedGroups()` is now `requestedFills()`. `GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed()` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `isWanted(sequence)` and `unwanted(sequence)` say when to stop serving one. A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. `Subscription.maxDelayUs` is a `ULong?` and `maxDelay` a `Duration?`, where null leaves the publisher's max age alone to bound delivery. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 9fab8969e1d..5dbb9300de7 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic.requested_group()` is now `requested_fill()`, returning a `Fill` (`start_group`, `start_frame`, `end_group`, `priority`, `max_delay`, `order`, `next()`, `accept(sequence)`, `drop_groups(start, end)`, `is_used()`, `unused()`), and iterating a `TrackDynamic` yields fills. `GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `is_used()` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `is_wanted(sequence)` and `unwanted(sequence)` say when to stop serving one. A refusal carries no application code: `drop_groups` fails the waiting fetches with `moq.Error.NotFound`. `Subscription.max_delay` also accepts `None`, leaving the publisher's max age alone to bound delivery. diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 96b90f3a0a6..ba7c2370ab0 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -199,7 +199,8 @@ class Fill: Call :meth:`accept` for each group you have and :meth:`drop_groups` for the rest; :meth:`next` names what is still owed, in the fill's order. Dropping the fill names whatever is left as dropped while a reader still wants it; once nobody does, it is - withdrawn, so stop serving it when :meth:`unused` returns. + withdrawn, so stop serving it when :meth:`unused` returns. Stop serving a single group + when :meth:`unwanted` returns for it, and move on to :meth:`next`. """ def __init__(self, inner: MoqFill) -> None: @@ -258,6 +259,17 @@ async def unused(self) -> None: """Wait until no reader waits on this fill.""" await self._inner.unused() + def is_wanted(self, sequence: int) -> bool: + """Whether a reader still reads group ``sequence``. + + False once every reader narrowed past it, or nobody waits on the fill. + """ + return self._inner.is_wanted(sequence) + + async def unwanted(self, sequence: int) -> None: + """Wait until no reader reads group ``sequence``, so stop serving it.""" + await self._inner.unwanted(sequence) + class TrackDynamic(_Stream[Fill]): """Async source of the fills readers of one track need: runs of groups the cache lacks. diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index b572a4e04f5..213bce020a9 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -515,6 +515,8 @@ async def test_fetch_group_and_serve_dynamic_miss(): assert fill.priority == 11 assert fill.next() == 7 assert fill.is_used() + assert fill.is_wanted(7) + assert not fill.is_wanted(8) produced = fill.accept(7) produced.write_frame(b"archive", timedelta(microseconds=140_000)) diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs index b09a16c4f73..4c9c11aafa0 100644 --- a/rs/moq-archive/src/reader/mod.rs +++ b/rs/moq-archive/src/reader/mod.rs @@ -414,16 +414,18 @@ async fn serve_timeline_group( return; } }; - let object = shared - .cache - .get_or_insert_async(&key, async { + let object = tokio::select! { + biased; + // Every reader narrowed past it: the fill moves on to the groups it still owes. + _ = fill.unwanted(sequence) => return, + object = shared.cache.get_or_insert_async(&key, async { shared .store .get_segments(name, segment, timescale.is_some()) .await .map(Arc::new) - }) - .await; + }) => object, + }; let object = match object { Ok(object) => object, Err(err) => { @@ -441,6 +443,9 @@ async fn serve_timeline_group( reject(); return; } + if !fill.is_wanted(sequence) { + return; + } let Ok(mut producer) = fill.accept(sequence, None) else { return; }; @@ -549,7 +554,13 @@ async fn serve_group( // Validate the first object before accepting, so an unreadable group is a plain miss. Keep it, // so a cache too small to hold it costs no second GET. let timed = timescale.is_some(); - let mut first = match object(shared, track, timed, &spans[0]).await { + let loaded = tokio::select! { + biased; + // Every reader narrowed past it: the fill moves on to the groups it still owes. + _ = fill.unwanted(sequence) => return, + loaded = object(shared, track, timed, &spans[0]) => loaded, + }; + let mut first = match loaded { Ok(object) => Some((spans[0].sequence, object)), Err(err) => { tracing::warn!(track, sequence, %err, "archived group is unavailable"); @@ -557,6 +568,9 @@ async fn serve_group( return; } }; + if !fill.is_wanted(sequence) { + return; + } let Ok(mut producer) = fill.accept(sequence, None) else { return; }; diff --git a/rs/moq-archive/src/reader/tests.rs b/rs/moq-archive/src/reader/tests.rs index d21f28732fe..86208657e40 100644 --- a/rs/moq-archive/src/reader/tests.rs +++ b/rs/moq-archive/src/reader/tests.rs @@ -763,6 +763,75 @@ async fn evicted_timeline_groups_remain_fetchable() { let _ = serve.await; } +/// A timeline group every reader narrowed past stops its GET, so the groups the fill still +/// owes are read rather than waiting behind it. +#[tokio::test(start_paused = true)] +async fn a_narrowed_timeline_read_skips_the_group_it_left() { + let mut archive = Archive::new().await; + for group in 0..3 { + archive.add("video", group..group + 1, 1).await; + } + let mut info = broadcast::Info::new(); + info.pool = moq_net::cache::Pool::new(moq_net::cache::Config::default().with_expiry(Duration::from_millis(100))); + let pool = info.pool.clone(); + let broadcast = info.produce(); + let reader = Reader::open(archive.store.clone(), &broadcast, archive.config()) + .await + .unwrap(); + let serve = tokio::spawn(reader.serve()); + let now = moq_net::time::Instant::now(); + pool.gc(now, &kio::Waiter::noop()); + pool.gc(now + Duration::from_secs(1), &kio::Waiter::noop()); + + let mock = archive.store.inner().clone(); + mock.take(); + let gate = mock.stall_gets("video%2Etimeline%2Ez/segments/"); + let ascending = |groups| { + moq_net::track::Subscription::default() + .with_max_delay(None) + .with_groups(groups) + .with_order(moq_net::track::Order::Ascending) + }; + let mut subscriber = broadcast + .consume() + .track(&timeline("video")) + .unwrap() + .subscribe(ascending(0..)) + .await + .unwrap(); + let waiter = kio::Waiter::noop(); + let mut gets = Vec::new(); + let _ = subscriber.poll_recv_group(&waiter); + assert!( + segment_read(&mock, &mut gets, 0).await, + "group 0 is read from its segment" + ); + + subscriber.update(ascending(1..)).unwrap(); + let _ = subscriber.poll_recv_group(&waiter); + assert!( + segment_read(&mock, &mut gets, 1).await, + "group 1 waited behind group 0, which nobody reads" + ); + gate.add_permits(16); + serve.abort(); + let _ = serve.await; +} + +/// Whether the video timeline's `segment` is requested once the runtime settles, collecting +/// GETs. Bounded by yields rather than time, so the paused clock stays put. +async fn segment_read(mock: &Mock, gets: &mut Vec, segment: u64) -> bool { + let path = format!("rec/video%2Etimeline%2Ez/segments/{segment:019}"); + for _ in 0..1000 { + gets.extend(mock.gets()); + if gets.contains(&path) { + return true; + } + tokio::task::yield_now().await; + } + false +} + #[tokio::test(start_paused = true)] async fn enrolled_timeline_fetch_survives_finish() { let mut archive = Archive::new().await; diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index b4b9b8c37c8..ffa4dc9e7a3 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. The ABI is unchanged. A track's dynamic handler now also gets group requests for history a subscription covers that the cache lacks, not only for fetches; each run arrives one group at a time, the next once the previous is accepted, aborted, or freed. `moq_group_request_abort` no longer delivers its error code: the waiting fetches fail as not found. +A request whose group nobody reads any more, because every subscriber narrowed past it, is withdrawn and the next group follows: answering it then fails as not found. `moq_subscription.max_delay_us` always sets a budget, so a C subscriber cannot ask for the publisher's max age alone. ### JSON snapshot `delta_ratio` is `deltas` diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index e2af706c183..7272c591743 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -2990,6 +2990,8 @@ pub unsafe extern "C" fn moq_publish_dynamic( /// [moq_publish_dynamic_cancel] was called) or a negative error. After the terminal /// (`<= 0`) callback, `user_data` is never touched again. Cached groups never reach the /// handler. Answer each request with [moq_group_request_accept] or [moq_group_request_abort]. +/// A request whose group nobody reads any more is withdrawn: answering it then fails as not +/// found, so move on to the next request. /// /// Returns a non-zero handle on success, or a negative code on failure. /// diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index e4db1b17203..0dc5c7a828e 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -783,8 +783,8 @@ impl Publish { } } - /// Hand `fill` to C a group at a time, each once the one before it is answered, until - /// every group is answered or nobody waits on it. + /// Hand `fill` to C a group at a time, each once the one before it is answered or nobody + /// reads it any more, until every group is answered or nobody waits on the fill. async fn serve_fill(callback: OnStatus, fill: Arc) { let mut last = None; while fill.is_used() @@ -802,10 +802,13 @@ impl Publish { return; }; callback.call(id); - let unused = kio::wait(|waiter| fill.poll_unused(waiter)); + let unwanted = kio::wait(|waiter| fill.poll_unwanted(sequence, waiter)); tokio::select! { _ = answered => {} - _ = unused => return, + // Withdrawn, so answering it fails as not found, and the fill moves on. + _ = unwanted => { + State::lock().publish.group_request.remove(id); + } } } } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index c25b89cb882..b4779db0c47 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3248,6 +3248,59 @@ fn track_dynamic_serves_a_fetch_miss() { assert_eq!(moq_origin_close(origin), 0); } +/// A group request every reader narrowed past is withdrawn, and the next group its fill +/// still owes is requested without waiting for an answer. +#[test] +fn a_group_request_nobody_reads_is_withdrawn() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"track-dynamic-narrowed"); + let name = b"data"; + let track = + id(unsafe { moq_publish_track(broadcast, name.as_ptr() as *const c_char, name.len(), std::ptr::null()) }); + let group_cb = Callback::new(); + let dynamic = id(unsafe { moq_publish_track_dynamic(track, Some(channel_callback), group_cb.ptr) }); + + let ascending = |groups| { + moq_net::track::Subscription::default() + .with_max_delay(None) + .with_groups(groups) + .with_order(moq_net::track::Order::Ascending) + }; + let mut reader = { + let mut state = State::lock(); + let producer = state.publish.track_producer(Id::try_from(track).unwrap()).unwrap(); + let reader = producer.subscribe(ascending(7..)); + producer.start_at(10).unwrap(); + let mut group = producer.create_group(moq_net::group::Info { sequence: 10 }).unwrap(); + group + .write_frame(moq_net::Timestamp::from_millis(10).unwrap(), &b"live"[..]) + .unwrap(); + group.finish().unwrap(); + reader + }; + let waiter = kio::Waiter::noop(); + while let std::task::Poll::Ready(Ok(Some(_))) = reader.poll_recv_group(&waiter) {} + let first = id(group_cb.recv()); + let mut sequence = 0u64; + assert_eq!(unsafe { moq_group_request_sequence(first, &mut sequence) }, 0); + assert_eq!(sequence, 7); + + reader.update(ascending(8..)).unwrap(); + assert!(reader.poll_recv_group(&waiter).is_pending()); + let next = id(group_cb.recv()); + assert_eq!(unsafe { moq_group_request_sequence(next, &mut sequence) }, 0); + assert_eq!(sequence, 8, "group 7, which nobody reads, held the fill back"); + assert!(moq_group_request_accept(first) < 0, "the withdrawn request"); + + assert_eq!(moq_group_request_free(next), 0); + drop(reader); + assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); + assert_eq!(group_cb.recv_terminal(), 0); + assert_eq!(moq_publish_track_finish(track), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + #[test] fn track_dynamic_serves_a_fetch_from_frame_start() { let origin = id(moq_origin_create()); diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index be2cb6775e4..56f5ceb9cde 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -9,6 +9,7 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it `MoqTrackDynamic::requested_group` is now `requested_fill`, returning a `MoqFill`: `start_group`, `start_frame`, `end_group`, `priority`, `max_delay_us`, `order`, `next`, `accept(sequence)`, `drop_groups(start, end)`, `is_used`, and `unused`. `MoqGroupRequest` and `MoqGroupDemand` are removed: serve each group `next()` names, and watch `is_used` or `unused` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `is_wanted(sequence)` and `unwanted(sequence)` say when to stop serving one. A refusal carries no application code: `drop_groups` fails the waiting fetches with `NotFound`. A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 3097fa79924..c245738b778 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -486,7 +486,8 @@ impl MoqTrackDynamic { /// Call [`accept`](Self::accept) for each group you have and [`drop_groups`](Self::drop_groups) /// for the rest; [`next`](Self::next) names what is still owed, in the fill's order. Releasing /// it names whatever is left as dropped while a reader still wants it; once nobody does, it is -/// withdrawn, so stop serving it when [`unused`](Self::unused) resolves. +/// withdrawn, so stop serving it when [`unused`](Self::unused) resolves. Stop serving a single +/// group when [`unwanted`](Self::unwanted) resolves for it, and move on to the next. #[derive(uniffi::Object)] pub struct MoqFill { inner: Arc, @@ -563,6 +564,22 @@ impl MoqFill { }) .await } + + /// Whether a reader still reads group `sequence`: false once every reader narrowed past + /// it, or nobody waits on the fill. + pub fn is_wanted(&self, sequence: u64) -> bool { + self.inner.is_wanted(sequence) + } + + /// Wait until no reader reads group `sequence`, so stop serving it. + pub async fn unwanted(&self, sequence: u64) -> Result<(), MoqError> { + let fill = self.inner.clone(); + crate::ffi::detached(async move { + fill.unwanted(sequence).await; + Ok::<_, MoqError>(()) + }) + .await + } } // ---- Track Request ---- diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 1d485b933d4..0c9cfe2ba88 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -2067,6 +2067,8 @@ async fn fill_goes_unused_when_the_fetcher_leaves() { .expect("timed out waiting for fill") .unwrap(); assert!(fill.is_used()); + assert!(fill.is_wanted(5)); + assert!(!fill.is_wanted(6), "outside the fill"); fetch.abort(); tokio::time::timeout(TIMEOUT, fill.unused()) @@ -2074,6 +2076,11 @@ async fn fill_goes_unused_when_the_fetcher_leaves() { .expect("timed out waiting for the abandoned fetch to become unused") .unwrap(); assert!(!fill.is_used()); + tokio::time::timeout(TIMEOUT, fill.unwanted(5)) + .await + .expect("an unused fill wants none of its groups") + .unwrap(); + assert!(!fill.is_wanted(5)); } #[tokio::test] diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 0f6e83c589b..4fabbbb58a5 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -9,6 +9,7 @@ How to update your code for each breaking change, newest first. `track::Dynamic::requested_group` and `poll_requested_group` are replaced by `requested_fill` and `poll_requested_fill`, which yield a `track::Fill`: a run from `start()` (group and frame) to the exclusive `end()`, with `priority()`, `max_delay()`, and `order()`. Call `accept(sequence, info)` for each group you have (the first starts at `start().frame`) and `drop_groups(range)` for the rest; `next()` names the next group still owed, in the fill's order. `group::Request` is removed, and its `demand()` with it: watch the fill's `is_used()` and `unused()` instead. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `is_wanted(sequence)`, `unwanted(sequence)`, and `poll_unwanted(sequence, waiter)` say when to stop serving one. A fill also arrives for history a subscriber's ranges cover below the live feed that the cache lacks, not only for `fetch_group`. Dropping a fill with groups unanswered names them dropped, where the live feed won't deliver them, while a reader still waits; a fill nobody waits on is withdrawn and records nothing. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 2ac53b4e5d1..f8f738958f2 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16013,14 +16013,15 @@ mod test { panic!("below the start is a miss"); }; assert_eq!(fill.next(), Some(0)); - let fetching = fill.group(0); - assert!(fetching.poll_unused(&waiter).is_pending()); + assert!(fill.is_wanted(0)); + assert!(fill.poll_unwanted(0, &waiter).is_pending()); subscriber.update(ascending(8..)).unwrap(); assert!(subscriber.poll_recv_group(&waiter).is_pending()); - assert!(fetching.poll_unused(&waiter).is_ready(), "nobody reads group 0 now"); - drop(fetching); - assert!(fill.group(8).poll_unused(&waiter).is_pending()); + assert!(fill.poll_unwanted(0, &waiter).is_ready(), "nobody reads group 0 now"); + assert!(!fill.is_wanted(0)); + assert!(fill.is_wanted(8)); + assert!(fill.poll_unwanted(8, &waiter).is_pending()); assert_eq!(fill.next(), Some(8), "nobody reads below 8 any more"); write_filled(fill.accept(8, None), 8); assert_eq!(fill.next(), Some(9)); @@ -16063,7 +16064,7 @@ mod test { }; assert_eq!((again.start().group, again.end()), (0, 8)); assert_eq!(fill.next(), Some(8), "the first fill still owes only 8 and up"); - assert!(fill.group(0).poll_unused(&waiter).is_ready()); + assert!(!fill.is_wanted(0), "a group stops being wanted for good"); } /// A descending reader's disjoint misses queue newest first, so a handler serving one fill diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index c92fa498681..f8f05dff840 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -384,6 +384,13 @@ impl FillState { } } + /// Whether a join of fill `id` still reads `sequence`. + fn wants(&self, id: u64, sequence: u64) -> bool { + self.pending + .get(&id) + .is_some_and(|pending| pending.wants.contains_key(&sequence)) + } + /// Take the oldest queued fill, still pending (joinable) until it ends. fn pop(&mut self) -> Option { let id = self.queue.pop_front()?; @@ -501,6 +508,8 @@ pub(super) fn poll_requested( /// it has and [`drop_groups`](Self::drop_groups) for the rest. Dropping the fill names /// whatever is left as dropped while a reader still wants it; once nobody does, the fill is /// withdrawn and records nothing, so stop serving it when [`unused`](Self::unused) resolves. +/// A reader narrowing its ranges may leave a single group, so stop serving that one when +/// [`unwanted`](Self::unwanted) resolves and move on to [`next`](Self::next). pub struct Fill { inner: Arc, } @@ -582,6 +591,23 @@ impl Fill { self.inner.result.poll_unused(waiter).map(|_| ()) } + /// Whether a reader still reads group `sequence`: false once every reader narrowed past + /// it, or nobody waits on the fill. A group stops being wanted for good. + pub fn is_wanted(&self, sequence: u64) -> bool { + self.inner.is_wanted(sequence) + } + + /// Wait until no reader reads group `sequence`, so stop serving it. + #[cfg(feature = "async")] + pub async fn unwanted(&self, sequence: u64) { + kio::wait(|waiter| self.poll_unwanted(sequence, waiter)).await + } + + /// Poll until no reader reads group `sequence`. + pub fn poll_unwanted(&self, sequence: u64, waiter: &kio::Waiter) -> Poll<()> { + self.inner.poll_unwanted(sequence, waiter) + } + /// The next group still owed in the fill's order: a reader still reads it, and it was /// neither answered nor delivered live since it was asked. `None` once none is owed. pub fn next(&self) -> Option { @@ -643,6 +669,22 @@ impl Inner { (self.start.group..self.end).contains(&sequence) } + fn is_wanted(&self, sequence: u64) -> bool { + self.result.is_used() && self.fills.read().wants(self.id, sequence) + } + + fn poll_unwanted(&self, sequence: u64, waiter: &kio::Waiter) -> Poll<()> { + if self.result.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + self.fills + .poll(waiter, |fills| match fills.wants(self.id, sequence) { + true => Poll::Pending, + false => Poll::Ready(()), + }) + .map(drop) + } + /// The runs of the fill a reader still reads, read before any other lock. fn wanted(&self) -> RangeSet { let span = self.start.group..self.end; @@ -858,26 +900,10 @@ impl GroupFill { self.frame_start } - /// Poll until no reader waits on this group, as when every reader narrowed past it. + /// Poll until no reader waits on this group, as [`Fill::poll_unwanted`]. // private: capability gap pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { - if self.inner.result.poll_unused(waiter).is_ready() { - return Poll::Ready(()); - } - let (id, sequence) = (self.inner.id, self.sequence); - self.inner - .fills - .poll(waiter, |fills| { - match fills - .pending - .get(&id) - .is_some_and(|pending| pending.wants.contains_key(&sequence)) - { - true => Poll::Pending, - false => Poll::Ready(()), - } - }) - .map(drop) + self.inner.poll_unwanted(self.sequence, waiter) } /// Poll until the track closes, with its abort error. diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index 1725d426e6e..ed50c4a1951 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -516,8 +516,9 @@ fn spawn_fill( /// Transcode group `sequence` of a fill, fetching it from the source. /// /// A failure while someone still wants the group drops it. Once nobody wants the -/// fill, it is left unanswered instead: the last caller already withdrew it, and -/// accepting afterwards would cache the group a fresh fill is about to serve. +/// group, it is left unanswered instead, releasing the permit for the groups the +/// fill still owes: its last reader already left it, and accepting afterwards would +/// cache the group a fresh fill is about to serve. async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Result<(), Error> { let reject = || fill.drop_groups(sequence..sequence + 1); // A fresh encode of this group need not match the bytes the reader's head @@ -541,7 +542,7 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul let options = moq_net::group::Fetch::default().with_priority(fill.priority()); let mut source = tokio::select! { biased; - _ = fill.unused() => return abandon(sequence), + _ = fill.unwanted(sequence) => return abandon(sequence), source = track.fetch_group(sequence, options) => match source { Ok(source) => source, Err(err) => { @@ -575,7 +576,7 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul // Accepting is what publishes the group. One last look, so a caller who // left while the pipeline was opening does not race a fresh fill. - if !fill.is_used() { + if !fill.is_wanted(sequence) { return abandon(sequence); } @@ -591,8 +592,8 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul Ok(()) } -/// Leave a fill nobody is waiting on: it was withdrawn, so this encode must not publish -/// the group a fresh fill is about to serve. +/// Leave a group nobody is waiting on: its fill no longer owes it, so this encode must not +/// publish the group a fresh fill is about to serve. fn abandon(sequence: u64) -> Result<(), Error> { tracing::debug!(sequence, "leaving a fetch nobody wants"); Ok(()) @@ -891,6 +892,55 @@ mod tests { (rung, source, trigger) } + /// A group every reader narrowed past releases the fill's one-at-a-time fetch, so the + /// groups the fill still owes are fetched rather than waiting behind it. + #[tokio::test] + #[allow(clippy::disallowed_methods)] + async fn a_group_nobody_reads_releases_its_fill() { + use std::task::Poll; + let (rung, source, _retire) = idle_rung(); + let source_fills = source.create_track("video", None).unwrap().dynamic(); + + let output = moq_net::broadcast::Info::default().produce(); + let timed = moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI); + let mut track = output.create_track("video/120p", timed).unwrap(); + let fills = track.dynamic(); + let ascending = |groups| { + moq_net::track::Subscription::default() + .with_max_delay(None) + .with_groups(groups) + .with_order(moq_net::track::Order::Ascending) + }; + let mut reader = track.subscribe(ascending(7..)); + track.start_at(10).unwrap(); + let mut live = track.create_group(moq_net::group::Info { sequence: 10 }).unwrap(); + live.write_frame(moq_net::Timestamp::from_millis(10).unwrap(), &b"x"[..]) + .unwrap(); + live.finish().unwrap(); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = reader.poll_recv_group(&waiter) {} + let fill = fills.requested_fill().await.unwrap(); + assert_eq!((fill.start().group, fill.end()), (7, 10)); + + let mut tasks = tokio::task::JoinSet::new(); + let permit = Arc::new(Semaphore::new(1)).try_acquire_owned().unwrap(); + spawn_fill(&mut tasks, rung, fill, permit); + let wait = std::time::Duration::from_secs(5); + let first = tokio::time::timeout(wait, source_fills.requested_fill()) + .await + .expect("group 7 is fetched from the source") + .unwrap(); + assert_eq!(first.start().group, 7); + + reader.update(ascending(8..)).unwrap(); + assert!(reader.poll_recv_group(&waiter).is_pending()); + let next = tokio::time::timeout(wait, source_fills.requested_fill()) + .await + .expect("the fill waited behind group 7, which nobody reads") + .unwrap(); + assert_eq!(next.start().group, 8); + } + #[tokio::test(start_paused = true)] async fn internal_idle_source_end_is_cancel() { use std::future::Future; diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index bcf1fb6f016..e10b6445e22 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -8,6 +8,7 @@ How to update your code for each breaking change, newest first. `TrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `Fill` (`startGroup`, `startFrame`, `endGroup`, `priority`, `maxDelayUs`, `order`, `next()`, `accept(sequence:)`, `dropGroups(start:end:)`, `isUsed`, `unused()`), and iterating a `TrackDynamic` yields fills. `GroupRequest` and `GroupDemand` are removed: serve each group `next()` names, and watch `isUsed` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `isWanted(sequence:)` and `unwanted(sequence:)` say when to stop serving one. A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. `Subscription.maxDelayUs` is a `UInt64?`, where nil leaves the publisher's max age alone to bound delivery. diff --git a/swift/Sources/Moq/Dynamic.swift b/swift/Sources/Moq/Dynamic.swift index 588a55c5a80..e5b70e36f3a 100644 --- a/swift/Sources/Moq/Dynamic.swift +++ b/swift/Sources/Moq/Dynamic.swift @@ -42,6 +42,7 @@ public final class TrackRequest: Sendable { /// Accept each group you have and drop the rest; `next()` names what is still owed, in the /// fill's order. Releasing the fill names whatever is left as dropped while a reader still /// wants it; once nobody does, it is withdrawn, so stop serving it when `unused()` returns. +/// Stop serving a single group when `unwanted(sequence:)` returns for it, and move on to `next()`. public final class Fill: Sendable { let ffi: MoqFill @@ -105,6 +106,17 @@ public final class Fill: Sendable { public func unused() async throws { try await ffi.unused() } + + /// Whether a reader still reads group `sequence`: false once every reader narrowed past + /// it, or nobody waits on the fill. + public func isWanted(sequence: UInt64) -> Bool { + ffi.isWanted(sequence: sequence) + } + + /// Suspend until no reader reads group `sequence`, so stop serving it. + public func unwanted(sequence: UInt64) async throws { + try await ffi.unwanted(sequence: sequence) + } } /// A stream of the fills readers of one track need: runs of groups the cache lacks. From 54e0a6e98986a0c056934590ad807e2d583f1621 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 19:22:30 -0700 Subject: [PATCH 28/48] fix(net): a SUBSCRIPTION_ENDED copy does not linger Only TRACK_ENDED marks the IETF copy finished, so after SUBSCRIPTION_ENDED the copy is released and a later reader subscribes afresh. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4cc39a6abff..27cd3f6ba40 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -3566,7 +3566,9 @@ where // A no-op once an END_OF_TRACK declared the end, whose readers wait // for a missing group only as long as the grace. let _ = track.finish(); - finished = true; + // Only TRACK_ENDED ends the track: after SUBSCRIPTION_ENDED a + // later reader subscribes afresh. + finished = matches!(ended, Some(Ok(()))); } } Err(err) => { @@ -15979,6 +15981,33 @@ mod joining_fetch_tests { } } + /// SUBSCRIPTION_ENDED ends only our subscription, not the track, so the copy is released + /// for a later reader to subscribe afresh rather than lingering as if the track ended. + #[moq_clock::test] + async fn a_subscription_ended_copy_does_not_linger() { + for version in JOINING_DRAFTS { + let mut ok = subscribe_ok(version, None); + ok.extend(message_bytes( + ietf::PublishDone::ID, + &ietf::PublishDone { + request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) + .then_some(RequestId(1)), + status_code: 0x3, + stream_count: 0, + reason_phrase: "done".into(), + }, + version, + )); + let mut run = JoinRun::start(version, None, ok, Vec::new()).await; + let _subscriber = (&mut run._hold.2).await.expect("subscribed"); + while !run.subscriber.state.lock().subscribes.is_empty() { + settle().await; + } + settle().await; + assert!(run.serving.is_finished(), "{version}: the copy is released"); + } + } + #[derive(Clone, Copy, PartialEq, Eq)] enum FetchStage { Unanswered, From b5d2930f44babe993d6db9b28d0817a437e3ced3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 19:39:17 -0700 Subject: [PATCH 29/48] doc(ffi): migrate the generated Go and Dart bindings to fills Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/MIGRATE.md | 12 +++++++++++- go/ffi/MIGRATE.md | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/dart/moq_ffi/MIGRATE.md b/dart/moq_ffi/MIGRATE.md index 8df537cb61c..fee8faa4f39 100644 --- a/dart/moq_ffi/MIGRATE.md +++ b/dart/moq_ffi/MIGRATE.md @@ -4,9 +4,19 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`MoqTrackDynamic.requestedGroup()` is now `requestedFill()`, returning a `MoqFill`: `startGroup()`, `startFrame()`, `endGroup()`, `priority()`, `maxDelayUs()`, `order()`, `next()`, `accept(sequence:)`, `dropGroups(start:, end:)`, `isUsed()`, and `unused()`. +`MoqGroupRequest` and `MoqGroupDemand` are removed: serve each group `next()` names, and watch `isUsed()` or `unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `isWanted(sequence:)` and `unwanted(sequence:)` say when to stop serving one. +A refusal carries no application code: `dropGroups` fails the waiting fetches as not found. +A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. +`MoqSubscription.maxDelayUs` is nullable, where null leaves the publisher's max age alone to bound delivery. +Regenerate the bindings with `just dart generate` for the changed types and checksums. + ### Priorities are signed (#6142) -`MoqTrackInfo.priority`, `MoqSubscription.priority`, `MoqFetchGroupOptions.priority`, and `MoqGroupRequest.priority()` take `-128..=127`, higher first, and 0 is the unset midpoint. +`MoqTrackInfo.priority`, `MoqSubscription.priority`, `MoqFetchGroupOptions.priority`, and `MoqFill.priority()` take `-128..=127`, higher first, and 0 is the unset midpoint. `MoqTrackInfo.priority` defaulted to 127 and now defaults to 0. To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. Regenerate the bindings for the changed checksums. diff --git a/go/ffi/MIGRATE.md b/go/ffi/MIGRATE.md index 5319a4adb04..da8b3af2338 100644 --- a/go/ffi/MIGRATE.md +++ b/go/ffi/MIGRATE.md @@ -4,9 +4,19 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Dynamic tracks serve fills + +`MoqTrackDynamic.RequestedGroup()` is now `RequestedFill()`, returning a `*MoqFill`: `StartGroup()`, `StartFrame()`, `EndGroup()`, `Priority()`, `MaxDelayUs()`, `Order()`, `Next()`, `Accept(sequence)`, `DropGroups(start, end)`, `IsUsed()`, and `Unused()`. +`MoqGroupRequest` and `MoqGroupDemand` are removed: serve each group `Next()` names, and watch `IsUsed()` or `Unused()` on the fill. +A reader narrowing its ranges can leave a single group while the fill stays used: `Next()` skips it, and `IsWanted(sequence)` and `Unwanted(sequence)` say when to stop serving one. +A refusal carries no application code: `DropGroups` fails the waiting fetches with `NotFound`. +A fill also arrives for history a subscription covers that the cache lacks, not only for a fetch. +`MoqSubscription.MaxDelayUs` is `*uint64`, where `nil` leaves the publisher's max age alone to bound delivery. +Regenerate the bindings for the changed types and checksums. + ### Priorities are signed (#6142) -`MoqTrackInfo.Priority`, `MoqSubscription.Priority`, `MoqFetchGroupOptions.Priority`, and `MoqGroupRequest.Priority()` are `int8`, higher first, and 0 is the unset midpoint. +`MoqTrackInfo.Priority`, `MoqSubscription.Priority`, `MoqFetchGroupOptions.Priority`, and `MoqFill.Priority()` are `int8`, higher first, and 0 is the unset midpoint. `MoqTrackInfo.Priority` defaulted to 127 and now defaults to 0. To keep a value's moq-lite byte, subtract 128 from it: 255 becomes 127, 128 becomes 0, and 0 becomes -128. Regenerate the bindings for the changed checksums. From 0016babf7df6ace13de8b4e11498052d8ca62933 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 20:06:57 -0700 Subject: [PATCH 30/48] fix(net): a wide fill forgets refusals its readers took A reader releases each refusal it takes from its join, and a run nobody reads any more is dropped from the fill's answers, so a fill refused group by group with differing errors holds what its readers have yet to take, not one entry per refusal. Also makes the moq-c withdrawal test deterministic: its reader reads only group 8, so letting 8 go leaves the fill owing nothing. Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/test.rs | 9 +++++---- rs/moq-net/src/model/track.rs | 24 ++++++++++++++++++++++++ rs/moq-net/src/model/track/fill.rs | 26 ++++++++++++++++++++++++-- rs/moq-net/src/model/track/miss.rs | 7 +++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index b4779db0c47..12e67839e92 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3260,16 +3260,16 @@ fn a_group_request_nobody_reads_is_withdrawn() { let group_cb = Callback::new(); let dynamic = id(unsafe { moq_publish_track_dynamic(track, Some(channel_callback), group_cb.ptr) }); - let ascending = |groups| { + let ascending = |start, end| { moq_net::track::Subscription::default() .with_max_delay(None) - .with_groups(groups) + .with_groups(start..end) .with_order(moq_net::track::Order::Ascending) }; let mut reader = { let mut state = State::lock(); let producer = state.publish.track_producer(Id::try_from(track).unwrap()).unwrap(); - let reader = producer.subscribe(ascending(7..)); + let reader = producer.subscribe(ascending(7, u64::MAX)); producer.start_at(10).unwrap(); let mut group = producer.create_group(moq_net::group::Info { sequence: 10 }).unwrap(); group @@ -3285,7 +3285,8 @@ fn a_group_request_nobody_reads_is_withdrawn() { assert_eq!(unsafe { moq_group_request_sequence(first, &mut sequence) }, 0); assert_eq!(sequence, 7); - reader.update(ascending(8..)).unwrap(); + // Reading only 8, so the fill owes nothing once 8 is let go. + reader.update(ascending(8, 9)).unwrap(); assert!(reader.poll_recv_group(&waiter).is_pending()); let next = id(group_cb.recv()); assert_eq!(unsafe { moq_group_request_sequence(next, &mut sequence) }, 0); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 3ffaa723e62..b13df210211 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16065,6 +16065,30 @@ mod test { assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); } + /// A wide fill refused group by group, each with its own error, holds only the answers its + /// readers have yet to take, not one per refusal. + #[test] + #[cfg(feature = "async")] + fn a_wide_fill_forgets_refusals_its_readers_took() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_groups(0..).with_order(Order::Ascending)); + producer.start_at(100).unwrap(); + write_group(&producer, 100); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 100)); + for sequence in 0..50 { + assert_eq!(fill.next(), Some(sequence)); + fill.group(sequence).reject(Error::App(sequence as u16)); + let _ = subscriber.poll_recv_group(&waiter); + assert!(fill.answers() <= 1, "group {sequence}: {} answers held", fill.answers()); + } + } + /// What a fill stopped owing is asked afresh by a reader that wants it later, rather than /// joining a fetch its handler is cancelling. #[test] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index f8f05dff840..43111cafab5 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -372,12 +372,27 @@ impl FillState { /// Count a join out of reading `groups` of fill `id`. What nobody reads any more is no /// longer routed to it: a reader wanting it later asks afresh rather than joining a fetch - /// being cancelled, so a sequence a fill stops owing stays unowed. + /// being cancelled, so a sequence a fill stops owing stays unowed. Its answers are + /// forgotten too, so a wide fill holds the answers its readers have yet to take, not one + /// per group its source answered. fn unwant(&mut self, id: u64, groups: ops::Range) { let Some(pending) = self.pending.get_mut(&id) else { return; }; - for run in pending.unwant(groups) { + let unwanted = pending.unwant(groups); + let answered = { + let outcome = pending.result.read(); + unwanted + .iter() + .any(|run| outcome.refused.overlaps(run) || outcome.accepted.overlaps(run)) + }; + if answered && let Ok(mut outcome) = pending.result.write() { + for run in &unwanted { + outcome.refused.remove(run.clone()); + outcome.accepted.remove(run.clone()); + } + } + for run in unwanted { for range in self.routed(id, &run) { self.covers.remove(range); } @@ -608,6 +623,13 @@ impl Fill { self.inner.poll_unwanted(sequence, waiter) } + /// How many answer runs the fill still holds for its readers. + #[cfg(test)] + pub(super) fn answers(&self) -> usize { + let outcome = self.inner.result.read(); + outcome.refused.iter().count() + outcome.accepted.iter().count() + } + /// The next group still owed in the fill's order: a reader still reads it, and it was /// neither answered nor delivered live since it was asked. `None` once none is owed. pub fn next(&self) -> Option { diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index f15ec56559a..a2a640ac65f 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -190,8 +190,14 @@ impl Misses { }; // Read only after the outcome's lock is released. let state = state.read(); + // A refusal taken is no longer owed this reader. An accept stays held: its group may + // still be arriving, and is cancelled once nobody reads it. + let mut taken = RangeSet::new(); for (groups, answer) in answers { self.wanted.remove(groups.clone()); + if answer.is_err() { + taken.insert(groups.clone()); + } match answer { // What the cache holds is filled, and the rest settled by run: a fill that // ended unanswered may span far more groups than were ever cached. @@ -209,6 +215,7 @@ impl Misses { } } drop(state); + self.joins[i].release(&taken); // Judged again: the same join may have more to answer, or park the waiter now. } retry From 95b8f3570f3210de272f8bac80056b98fd35545b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 20:55:32 -0700 Subject: [PATCH 31/48] fix(net): a descending reader's split miss queues newest first A miss split by a fill already in flight queues its fragments in the reader's order. Also adds Fill::frame_start(sequence), replacing the copies in moq-archive, moq-c, and moq-transcode, and pauses the transcode test's clock. Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/reader/mod.rs | 13 ++----------- rs/moq-c/src/publish.rs | 17 +++++------------ rs/moq-net/MIGRATE.md | 2 +- rs/moq-net/src/model/track.rs | 26 ++++++++++++++++++++++++++ rs/moq-net/src/model/track/fill.rs | 12 +++++++++++- rs/moq-transcode/src/rung.rs | 8 ++++---- 6 files changed, 49 insertions(+), 29 deletions(-) diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs index 4c9c11aafa0..7f6163cea51 100644 --- a/rs/moq-archive/src/reader/mod.rs +++ b/rs/moq-archive/src/reader/mod.rs @@ -379,15 +379,6 @@ async fn serve_timeline(shared: Arc>, requests: Timeli .await; } -/// Where group `sequence` of `fill` starts. -fn frame_start(fill: &track::Fill, sequence: u64) -> u64 { - let start = fill.start(); - match start.group == sequence { - true => start.frame, - false => 0, - } -} - async fn serve_timeline_group( shared: &Shared, name: &str, @@ -434,7 +425,7 @@ async fn serve_timeline_group( return; } }; - let start = frame_start(fill, sequence); + let start = fill.frame_start(sequence); let Some(stored) = object.groups.iter().find(|group| group.sequence == sequence) else { reject(); return; @@ -539,7 +530,7 @@ async fn serve_group( fill: &track::Fill, sequence: u64, ) { - let start = frame_start(fill, sequence); + let start = fill.frame_start(sequence); let mut changed = shared.changed.subscribe(); let mut spans = shared.index.lock().unwrap().group(track, sequence); diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index 0dc5c7a828e..8d294842213 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -40,17 +40,6 @@ struct GroupRequest { _pending: oneshot::Sender<()>, } -impl GroupRequest { - /// Where the group starts: the fill's start frame for its first group. - fn frame_start(&self) -> u64 { - let start = self.fill.start(); - match start.group == self.sequence { - true => start.frame, - false => 0, - } - } -} - /// What a request handler serves. enum Dynamic { /// Track requests on a broadcast, remembered so media can be published onto them. @@ -884,7 +873,11 @@ impl Publish { /// The sequence, priority, and first frame of a requested group. pub fn group_request_info(&self, request: Id) -> Result<(u64, i8, u64), Error> { let request = self.group_request.get(request).ok_or(Error::NotFound)?; - Ok((request.sequence, request.fill.priority(), request.frame_start())) + Ok(( + request.sequence, + request.fill.priority(), + request.fill.frame_start(request.sequence), + )) } /// Accept a group request, returning a group handle like [`Self::track_group`]. diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 0f21ba05cff..2c1ae6aeaed 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -6,7 +6,7 @@ How to update your code for each breaking change, newest first. ### `Dynamic` serves fills, not group requests -`track::Dynamic::requested_group` and `poll_requested_group` are replaced by `requested_fill` and `poll_requested_fill`, which yield a `track::Fill`: a run from `start()` (group and frame) to the exclusive `end()`, with `priority()`, `max_delay()`, and `order()`. +`track::Dynamic::requested_group` and `poll_requested_group` are replaced by `requested_fill` and `poll_requested_fill`, which yield a `track::Fill`: a run from `start()` (group and frame) to the exclusive `end()`, where `frame_start(sequence)` is the frame each group starts from, with `priority()`, `max_delay()`, and `order()`. Call `accept(sequence, info)` for each group you have (the first starts at `start().frame`) and `drop_groups(range)` for the rest; `next()` names the next group still owed, in the fill's order. `group::Request` is removed, and its `demand()` with it: watch the fill's `is_used()` and `unused()` instead. A reader narrowing its ranges can leave a single group while the fill stays used: `next()` skips it, and `is_wanted(sequence)`, `unwanted(sequence)`, and `poll_unwanted(sequence, waiter)` say when to stop serving one. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index b13df210211..09953e55dc2 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16138,6 +16138,32 @@ mod test { assert_eq!(asked, [(4, 6), (0, 2)]); } + /// A descending reader's miss split by a fill already in flight queues its newer fragment + /// first too. + #[test] + #[cfg(feature = "async")] + fn a_descending_reader_asks_its_newest_fragment_first() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut middle = producer.subscribe(replay().with_groups(5..6)); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = middle.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(taken)) = dynamic.poll_requested_fill(&waiter) else { + panic!("group 5 is a miss"); + }; + assert_eq!((taken.start().group, taken.end()), (5, 6)); + + let mut subscriber = producer.subscribe(replay().with_groups(0..).with_order(Order::Descending)); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) { + asked.push((fill.start().group, fill.end())); + } + assert_eq!(asked, [(6, 8), (0, 5)]); + } + /// A group the handler refused that still arrives live is delivered. #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 43111cafab5..57432be60a5 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -298,7 +298,11 @@ impl FillState { .overlapping(&span) .map(|(groups, id)| (groups.start.max(span.start)..groups.end.min(span.end), *id)) .collect(); - let gaps: Vec> = self.covers.gaps(&span).collect(); + let mut gaps: Vec> = self.covers.gaps(&span).collect(); + // Queued in the reader's order, so a descending reader's newest fragment goes first. + if ask.order == Order::Descending { + gaps.reverse(); + } for (groups, id) in covered { let pending = self .pending @@ -549,6 +553,12 @@ impl Fill { self.inner.start } + /// The frame group `sequence` starts from: [`start`](Self::start)'s frame for the first + /// group, 0 for the rest. + pub fn frame_start(&self, sequence: u64) -> u64 { + self.inner.frame_start(sequence) + } + /// The exclusive end group. pub fn end(&self) -> u64 { self.inner.end diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index ed50c4a1951..ee230abc203 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -525,9 +525,9 @@ async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Resul // came from (the live encoder, or an earlier fetch), so its tail cannot // continue that head. Refuse it so the reader moves on to the next group // boundary instead of decoding a splice. - let start = fill.start(); - if start.group == sequence && start.frame != 0 { - tracing::debug!(sequence, frame = start.frame, "refusing a fetch that starts mid-group"); + let frame = fill.frame_start(sequence); + if frame != 0 { + tracing::debug!(sequence, frame, "refusing a fetch that starts mid-group"); reject(); return Ok(()); } @@ -894,7 +894,7 @@ mod tests { /// A group every reader narrowed past releases the fill's one-at-a-time fetch, so the /// groups the fill still owes are fetched rather than waiting behind it. - #[tokio::test] + #[tokio::test(start_paused = true)] #[allow(clippy::disallowed_methods)] async fn a_group_nobody_reads_releases_its_fill() { use std::task::Poll; From 24aadef28780e30660d0d456b2db9bbc25da6c3f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 23:02:20 -0700 Subject: [PATCH 32/48] fix(net): a reader forgets misses behind its window Judging clips what a reader gave up and had refused to the span it judges, as it already clipped what it judged. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 32 ++++++++++++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 22 +++++++++++--------- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 09953e55dc2..5c6271656d8 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16138,6 +16138,38 @@ mod test { assert_eq!(asked, [(4, 6), (0, 2)]); } + /// A reader forgets the misses it gave up once its window moves past them, as it forgets + /// what it judged, so a feed skipping every other group costs it only the gaps it can + /// still read. + #[test] + #[cfg(feature = "async")] + fn a_reader_forgets_misses_behind_its_window() { + let mut producer = track_producer("test", Info::timed()); + let _dynamic = producer.dynamic(); + let ascending = |groups| Subscription::default().with_groups(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(0).unwrap(); + for sequence in (0..20).step_by(2) { + write_group(&producer, sequence); + drain_groups(&mut subscriber); + elapse(&producer, Duration::from_secs(2)); + drain_groups(&mut subscriber); + } + let given_up = |subscriber: &Subscriber| -> Vec<(u64, u64)> { + let Inner::Plain(cursor) = &subscriber.inner else { + unreachable!("a plain subscription"); + }; + let misses = cursor.misses.as_ref().expect("the reader judged its misses"); + misses.given_up().iter().map(|run| (run.start, run.end)).collect() + }; + assert_eq!(given_up(&subscriber).len(), 9, "each skipped group is given up"); + + subscriber.update(ascending(15..)).unwrap(); + write_group(&producer, 20); + drain_groups(&mut subscriber); + assert_eq!(given_up(&subscriber), [(15, 16), (17, 18)]); + } + /// A descending reader's miss split by a fill already in flight queues its newer fragment /// first too. #[test] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index a2a640ac65f..25f684fa6b1 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -264,15 +264,19 @@ impl Misses { span.insert((*run.start()).max(low)..(*run.end()).min(top) + 1); } } - // What left the span is forgotten, so it is judged afresh if it comes back. - let judged: RangeSet = self - .judged - .iter() - .flat_map(|run| { - span.overlapping(run) - .map(move |s| s.start.max(run.start)..s.end.min(run.end)) - }) - .collect(); + // What left the span is forgotten, so it is judged afresh if it comes back, and a + // verdict behind the reader's floor stops costing it memory. + let clip = |set: &RangeSet| -> RangeSet { + set.iter() + .flat_map(|run| { + span.overlapping(run) + .map(move |s| s.start.max(run.start)..s.end.min(run.end)) + }) + .collect() + }; + let judged = clip(&self.judged); + self.refused = clip(&self.refused); + self.given_up = clip(&self.given_up); let mut candidates: RangeSet = span.iter().flat_map(|run| judged.gaps(run)).collect(); for run in std::mem::take(&mut self.deferred).iter() { for s in span.overlapping(run) { From 4a89d3db62ecfa9757d39c65a45c5761d854b77c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 01:07:01 -0700 Subject: [PATCH 33/48] fix(interop): the Go finish client passes an optional max delay Co-Authored-By: Claude Opus 5.5 --- test/interop/clients/go/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/interop/clients/go/main.go b/test/interop/clients/go/main.go index d65b6508fd3..69d926d2dc0 100644 --- a/test/interop/clients/go/main.go +++ b/test/interop/clients/go/main.go @@ -329,7 +329,8 @@ func finishSubscribe(ctx context.Context, client *moq.Client, broadcast string, for id, video = range catalog.Video { break } - media, err := moqmedia.NewTrackConsumer(ctx, consumer, moqmedia.TrackConfig{Rendition: moqmedia.VideoRendition(id, video), Subscription: &moq.Subscription{MaxDelay: maxDelay}}) + delay := maxDelay + media, err := moqmedia.NewTrackConsumer(ctx, consumer, moqmedia.TrackConfig{Rendition: moqmedia.VideoRendition(id, video), Subscription: &moq.Subscription{MaxDelay: &delay}}) if err != nil { return err } From 39dbe4476513ecb4bd26cbfa56f8abb73c39ea65 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 02:18:53 -0700 Subject: [PATCH 34/48] fix(net): narrowing releases accepted groups; time starts before the cache ask no older fills A reader narrowing its ranges now releases the accepted groups its fill is still writing, not only the unanswered ones, so `Fill::unwanted` fires for them. A time start that falls before every held group no longer asks fills below the oldest one: which of those groups it reaches is unknown until they start, so a cold relay backfilled history from group 0, before the anchor. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 31 +++++++++- rs/moq-net/src/model/track.rs | 67 ++++++++++++++++++++- rs/moq-net/src/model/track/miss.rs | 8 ++- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 0867c7e561e..8bd57e87b85 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -112,6 +112,8 @@ pub(in crate::model) struct Window { spans: Vec, /// The groups the spans cover, an unsettled time end through its cap. groups: RangeInclusiveSet, + /// The part of `groups` a miss may ask for: see [`Self::missable`]. + missable: RangeInclusiveSet, /// A relative anchor waits on a first group, so nothing is filtered yet. pending: bool, /// Nothing is left to resolve: see [`Self::is_settled`]. Kept so a read checks it once @@ -128,6 +130,9 @@ struct Span { end: End, /// Whether the start names a group, rather than reaching back to the oldest one. floored: bool, + /// The lowest group a miss asks for. A time start before every group held asks none + /// below the oldest: which of them it reaches is unknown until they start. + reach: u64, } impl Span { @@ -317,6 +322,7 @@ impl Window { ranges: ranges.to_vec(), spans: Vec::new(), groups: RangeInclusiveSet::new(), + missable: RangeInclusiveSet::new(), pending: false, settled: false, }; @@ -476,9 +482,23 @@ impl Window { /// The groups in range from `from` on, lowest first. pub fn runs(&self, from: u64) -> impl Iterator> + '_ { + self.from(&self.groups, from) + } + + /// The groups in range from `from` on that a miss may ask for, lowest first: all of + /// them but those below the oldest group held when a time start fell before it. + pub fn missable(&self, from: u64) -> impl Iterator> + '_ { + self.from(&self.missable, from) + } + + fn from<'a>( + &'a self, + groups: &'a RangeInclusiveSet, + from: u64, + ) -> impl Iterator> + 'a { let all = self.pending.then_some(from..=u64::MAX); let runs = (!self.pending).then(|| { - self.groups + groups .overlapping(from..=u64::MAX) .map(move |run| (*run.start()).max(from)..=*run.end()) }); @@ -546,11 +566,16 @@ impl Window { } end => end, }; + let reach = match range.start { + Anchor::Time(_) if !floored => holdings.next_start(0).map_or(start, |(oldest, _)| oldest), + _ => start, + }; self.spans.push(Span { index, start, end, floored, + reach, }); } self.rebuild(); @@ -560,6 +585,7 @@ impl Window { fn rebuild(&mut self) { self.settled = !self.pending && !self.spans.iter().any(|span| matches!(span.end, End::Time(_))); self.groups = RangeInclusiveSet::new(); + self.missable = RangeInclusiveSet::new(); for span in &self.spans { let last = match span.limit() { Some(end) => match end.checked_sub(1) { @@ -569,6 +595,9 @@ impl Window { None => u64::MAX, }; self.groups.insert(span.start..=last); + if span.reach <= last { + self.missable.insert(span.reach..=last); + } } } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 296f84dd1da..838f2d56848 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5953,14 +5953,14 @@ impl Cursor { let misses = self.misses.get_or_insert_with(Box::default); let mut given_up = Vec::new(); if misses.lowest().is_some() { - misses.retain(from, &admitted(window, from, cap)); + misses.retain(from, &below(window.missable(from), cap)); } let asks = { let state = self.state.read(); misses.judge( &state, (from, revision, cap), - || admitted(window, from, cap), + || below(window.missable(from), cap), drift, &mut given_up, waiter, @@ -16171,6 +16171,69 @@ mod test { assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); } + /// A group the fill accepted but is still writing stops being wanted once its reader + /// narrows past it, though the fill still owes that reader later groups. + #[test] + #[cfg(feature = "async")] + fn a_narrowed_reader_leaves_an_accepted_group() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!(fill.next(), Some(0)); + let _writing = fill.accept(0, None).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_ready(), "the accepted group is read"); + assert!(fill.is_wanted(0)); + + subscriber.update(ascending(8..)).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(!fill.is_wanted(0), "nobody reads group 0 now"); + assert!(fill.poll_unwanted(0, &waiter).is_ready()); + assert!(fill.is_wanted(8)); + } + + /// A time start before every group held asks no fill below the oldest, since which of + /// those groups it reaches is unknown until they start; another range still asks for its + /// own misses. + #[test] + #[cfg(feature = "async")] + fn a_time_start_before_the_cache_asks_nothing_below_it() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let since = Range { + start: Anchor::Time(at(5)), + end: None, + }; + let mut subscriber = producer.subscribe( + replay() + .with_ranges([Range::groups(0..2), since]) + .with_order(Order::Ascending), + ); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + let mut read = drain_groups(&mut subscriber); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the group range misses its groups"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 2), "nothing below 10 for the time start"); + while let Some(sequence) = fill.next() { + write_filled(fill.accept(sequence, None), sequence); + } + drop(fill); + read.extend(drain_groups(&mut subscriber)); + assert_eq!(read, [10, 0, 1]); + assert!(dynamic.poll_requested_fill(&waiter).is_pending()); + } + /// A wide fill refused group by group, each with its own error, holds only the answers its /// readers have yet to take, not one per refusal. #[test] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 25f684fa6b1..1b1f1245e99 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -122,7 +122,7 @@ impl Misses { /// no longer owe it those groups, and one nobody waits on is withdrawn once its last join /// goes. pub fn retain(&mut self, from: u64, admitted: &RangeInclusiveSet) { - if self.wanted.is_empty() && self.filled.is_empty() { + if self.wanted.is_empty() && self.filled.is_empty() && self.joins.is_empty() { return; } let outside = |sequence: u64| sequence < from || !admitted.contains(&sequence); @@ -133,11 +133,13 @@ impl Misses { kept.insert(inside); } } - let lost: RangeSet = self.wanted.iter().flat_map(|run| kept.gaps(run)).collect(); self.wanted = kept; let wanted = &self.wanted; self.joins.retain_mut(|join| { - join.release(&lost); + // By the join's whole span, not just what is unanswered: an accepted group still + // being written is held too. + let inside: RangeSet = clip(join.groups.clone(), from, admitted).into_iter().collect(); + join.release(&inside.gaps(&join.groups).collect()); wanted.overlaps(&join.groups) }); } From f8cfe468c421528a9fa47a5da0bc3ba8f50334cf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 02:34:23 -0700 Subject: [PATCH 35/48] style(net): format the new fill tests Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 838f2d56848..598667e750e 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16189,7 +16189,10 @@ mod test { }; assert_eq!(fill.next(), Some(0)); let _writing = fill.accept(0, None).unwrap(); - assert!(subscriber.poll_recv_group(&waiter).is_ready(), "the accepted group is read"); + assert!( + subscriber.poll_recv_group(&waiter).is_ready(), + "the accepted group is read" + ); assert!(fill.is_wanted(0)); subscriber.update(ascending(8..)).unwrap(); @@ -16224,7 +16227,11 @@ mod test { let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { panic!("the group range misses its groups"); }; - assert_eq!((fill.start().group, fill.end()), (0, 2), "nothing below 10 for the time start"); + assert_eq!( + (fill.start().group, fill.end()), + (0, 2), + "nothing below 10 for the time start" + ); while let Some(sequence) = fill.next() { write_filled(fill.accept(sequence, None), sequence); } From 30f0fda9223a4e6965595b5d03c26d7a523d99c1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 03:02:58 -0700 Subject: [PATCH 36/48] fix(net): fill sessions watch an accepted group's own readers A lite or IETF session writing an accepted group stopped only once nobody read the whole fill, so a stalled group every reader narrowed past held up the fill's later groups. It now stops once nobody reads that group. To keep per-group demand exact, a reader's join holds each filled group until the reader takes it from the cache, rather than letting go as soon as the fill answered it in full. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 6 +-- rs/moq-net/src/lite/subscriber.rs | 20 +++++----- rs/moq-net/src/model/track.rs | 62 ++++++++++++++++++++++-------- rs/moq-net/src/model/track/fill.rs | 48 ++++++++++++++++++++++- rs/moq-net/src/model/track/miss.rs | 35 ++++++++++++----- 5 files changed, 131 insertions(+), 40 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 7fd94a383f0..d72744519d5 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -5393,8 +5393,8 @@ where // stream also owes every object up to it. A whole-group answer has no object bound. let end = (end.group == sequence && !whole_group).then_some(end.object); - // Joined fetches still count until they pick the accepted group up from the cache. - let joined = request.joined(); + // The group's readers still count until they pick it up from the cache. + let readers = request.readers(); // The track was accepted before any fetch was asked, so it keeps that info. let track = request.track(); let mut producer = match request.accept(None) { @@ -5457,7 +5457,7 @@ where slot.poll(waiter, |state| match &**state { GroupFetch::Done => Poll::Ready(()), - _ if joined.poll_unused(waiter).is_ready() + _ if readers.poll_unused(waiter).is_ready() && demand.poll_unused(waiter).is_ready() && demand.abort_unused(Error::Cancel) => { diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index f68cc7035ff..af6034341c6 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -7708,9 +7708,8 @@ enum FetchRunState { stream: Stream, // Boxed, as is the ingest, so the other states stay small. producer: Box, - // The accepted request's demand: fetches that joined it but have not yet - // picked the group up from the cache. - joined: kio::Producer, + // The accepted group's readers that have not yet picked it up from the cache. + readers: track::FillReaders, ingest: Box, }, Done, @@ -7937,9 +7936,9 @@ impl kio::Task for FetchServeRun { // (a fetch with no live subscription); otherwise the group inherits the // accepted info. let group_info = self.info.clone(); - // The joined fetches pick the group up from the cache only when next - // polled, so their demand outlives the request until then. - let joined = request.joined(); + // The group's readers pick it up from the cache only when next polled, + // so their demand outlives the request until then. + let readers = request.readers(); let mut producer = match request.accept(group_info) { Ok(producer) => producer, Err(err) => { @@ -7961,7 +7960,7 @@ impl kio::Task for FetchServeRun { self.state = FetchRunState::Ingest { stream, producer: Box::new(crate::recv::Group::new(producer)), - joined, + readers, ingest: Box::new(FrameIngest::new( lite::Framing::new(&self.info, self.serve.subscriber.version), self.serve.subscriber.version, @@ -7973,15 +7972,16 @@ impl kio::Task for FetchServeRun { FetchRunState::Ingest { stream, producer, - joined, + readers, ingest, } => { let Poll::Ready(res) = ingest.poll(&mut stream.reader, producer, waiter) else { // Still short of its end, so completion always wins. Once nobody - // wants the rest (no joined fetch left to pick it up, no reader), + // wants the rest (no reader left to pick it up, none reading it), // cancel upstream and abort the truncated group, never caching it // as complete. The abort is atomic with a new reader arriving. - if joined.poll_unused(waiter).is_pending() || producer.handle().poll_unused(waiter).is_pending() + if readers.poll_unused(waiter).is_pending() + || producer.handle().poll_unused(waiter).is_pending() { return Poll::Pending; } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 598667e750e..679bef30872 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -36,7 +36,7 @@ use fill::FillState; // private: capability gap #[cfg_attr(not(feature = "async"), allow(unused_imports))] -pub(crate) use fill::{GroupFill, Outcome as FillOutcome}; +pub(crate) use fill::{GroupFill, Readers as FillReaders}; use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, @@ -14600,9 +14600,9 @@ mod test { assert!(futures::poll!(widest.as_mut()).is_pending()); assert_eq!(request.frame_start(), 2, "the in-flight range is already on the wire"); - // A handler may keep tracking the joined demand past the accept, as the lite + // A handler may keep tracking the group's readers past the accept, as the lite // subscriber does, which holds the result channel open. - let _joined = request.joined(); + let _readers = request.readers(); let mut group = request.accept(Info::timed()).unwrap(); // A handler numbers the frames from where it was asked to start, as `serve_fetch` // does; that offset is what makes the cached group too narrow for `widest`. @@ -16171,11 +16171,12 @@ mod test { assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); } - /// A group the fill accepted but is still writing stops being wanted once its reader - /// narrows past it, though the fill still owes that reader later groups. + /// A group the fill accepted stays wanted until its reader takes it, even once the rest of + /// the fill is answered, so its source keeps writing it meanwhile; narrowing past it + /// before then leaves it. #[test] #[cfg(feature = "async")] - fn a_narrowed_reader_leaves_an_accepted_group() { + fn a_filled_group_is_wanted_until_taken() { let mut producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); let ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); @@ -16187,19 +16188,48 @@ mod test { let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { panic!("below the start is a miss"); }; - assert_eq!(fill.next(), Some(0)); - let _writing = fill.accept(0, None).unwrap(); - assert!( - subscriber.poll_recv_group(&waiter).is_ready(), - "the accepted group is read" - ); - assert!(fill.is_wanted(0)); + let _writing = [fill.accept(0, None).unwrap(), fill.accept(1, None).unwrap()]; + for sequence in 2..10 { + fill.group(sequence).reject(Error::NotFound); + } + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the first filled group is read"); + }; + assert_eq!(group.sequence, 0); + assert!(!fill.is_wanted(0), "taken: its reader holds the group itself"); + assert!(fill.is_wanted(1), "filled, not taken yet"); subscriber.update(ascending(8..)).unwrap(); assert!(subscriber.poll_recv_group(&waiter).is_pending()); - assert!(!fill.is_wanted(0), "nobody reads group 0 now"); - assert!(fill.poll_unwanted(0, &waiter).is_ready()); - assert!(fill.is_wanted(8)); + assert!(!fill.is_wanted(1), "nobody reads group 1 now"); + assert!(fill.poll_unwanted(1, &waiter).is_ready()); + } + + /// A session still writing an accepted group stops once every reader narrowed past it, + /// though the fill still owes them later groups. + #[test] + #[cfg(feature = "async")] + fn an_accepted_groups_readers_leave_before_its_fill() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let ascending = |groups| replay().with_groups(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + let group = fill.group(0); + let readers = group.readers(); + let _writing = group.accept(None).unwrap(); + assert!(readers.poll_unused(&waiter).is_pending()); + + subscriber.update(ascending(1..)).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(readers.poll_unused(&waiter).is_ready(), "nobody reads group 0 now"); + assert!(fill.poll_unused(&waiter).is_pending(), "the fill still owes 1 and up"); } /// A time start before every group held asks no fill below the oldest, since which of diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 57432be60a5..f3769ed1999 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -227,6 +227,11 @@ impl Join { pending.result.weak().same_channel(&self.attempt) } + /// Whether the reader holds none of the fill any more. + pub(super) fn is_empty(&self) -> bool { + self.held.is_empty() + } + /// Stop reading `lost`, so the fill no longer owes this reader any of it. pub(super) fn release(&mut self, lost: &RangeSet) { let released: Vec> = lost @@ -896,6 +901,34 @@ impl TrackState { } } +/// The readers of one group of a fill, outliving its [`GroupFill`]. +#[cfg_attr(not(feature = "async"), allow(dead_code))] +// private: capability gap +pub(crate) struct Readers { + fills: kio::Shared, + id: u64, + sequence: u64, + outcome: kio::Producer, +} + +#[cfg_attr(not(feature = "async"), allow(dead_code))] +impl Readers { + /// Poll until no reader waits on the group. Once the fill ends its per-group demand goes + /// with it, so then until none of its readers is left. + // private: capability gap + pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + if self.outcome.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + self.fills + .poll(waiter, |fills| match fills.pending.get(&self.id) { + Some(pending) if !pending.wants.contains_key(&self.sequence) => Poll::Ready(()), + _ => Poll::Pending, + }) + .map(drop) + } +} + /// One group of a [`Fill`], for a session serving it a group at a time. Answering it answers /// that group of the fill; dropping it unanswered leaves the group to the fill. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -951,12 +984,25 @@ impl GroupFill { self.inner.result.is_used() } - /// The fill's readers, for a session keeping a group alive while any still wait. + /// The fill's answers, kept open as long as this handle lives. + #[cfg(test)] // private: capability gap pub(crate) fn joined(&self) -> kio::Producer { self.inner.result.clone() } + /// The group's readers, for a session still writing it once accepted. Holds the fill's + /// answers open, as the group does not answer its readers until they take it. + // private: capability gap + pub(crate) fn readers(&self) -> Readers { + Readers { + fills: self.inner.fills.clone(), + id: self.inner.id, + sequence: self.sequence, + outcome: self.inner.result.clone(), + } + } + /// The track the fill serves. #[cfg_attr(not(feature = "async"), allow(dead_code))] // private: capability gap diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 1b1f1245e99..611c0d1be2a 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -109,6 +109,7 @@ impl Misses { return Some(slot.serving().clone()); } self.filled.remove(&sequence); + self.release(sequence); self.give_up(sequence..sequence + 1, given_up); } } @@ -116,6 +117,18 @@ impl Misses { /// Take `sequence` as handed out. pub fn take_filled(&mut self, sequence: u64) { self.filled.remove(&sequence); + self.release(sequence); + } + + /// Stop holding a filled `sequence` in its fill: handed out, the group's own reader + /// keeps it wanted, or nothing does. + fn release(&mut self, sequence: u64) { + let mut lost = RangeSet::new(); + lost.insert(sequence..sequence + 1); + self.joins.retain_mut(|join| { + join.release(&lost); + !join.is_empty() + }); } /// Forget what the reader no longer reads: below `from`, or outside `admitted`. Its fills @@ -134,13 +147,12 @@ impl Misses { } } self.wanted = kept; - let wanted = &self.wanted; self.joins.retain_mut(|join| { - // By the join's whole span, not just what is unanswered: an accepted group still - // being written is held too. + // By the join's whole span, not just what is unanswered: a filled group not + // taken yet is held too. let inside: RangeSet = clip(join.groups.clone(), from, admitted).into_iter().collect(); join.release(&inside.gaps(&join.groups).collect()); - wanted.overlaps(&join.groups) + !join.is_empty() }); } @@ -163,7 +175,11 @@ impl Misses { .map(|run| run.start.max(join.groups.start)..run.end.min(join.groups.end)) .collect(); if wanted.is_empty() { - self.joins.swap_remove(i); + // Holding only filled groups until they are taken, it has nothing to answer. + match join.is_empty() { + true => drop(self.joins.swap_remove(i)), + false => i += 1, + } continue; } let answers = match join.outcome().poll(waiter, |outcome| { @@ -192,14 +208,12 @@ impl Misses { }; // Read only after the outcome's lock is released. let state = state.read(); - // A refusal taken is no longer owed this reader. An accept stays held: its group may - // still be arriving, and is cancelled once nobody reads it. + // What is answered is no longer owed this reader, except a filled group: held until + // taken, so its source keeps writing it while the reader picks it up. let mut taken = RangeSet::new(); for (groups, answer) in answers { self.wanted.remove(groups.clone()); - if answer.is_err() { - taken.insert(groups.clone()); - } + taken.insert(groups.clone()); match answer { // What the cache holds is filled, and the rest settled by run: a fill that // ended unanswered may span far more groups than were ever cached. @@ -208,6 +222,7 @@ impl Misses { for (&sequence, slot) in state.lookup.range(groups) { if !slot.is_aborted() { self.filled.insert(sequence); + taken.remove(sequence..sequence + 1); } } } From 434fa51c7d45fdc66ba69bbddff8d78b00615a7a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 03:41:53 -0700 Subject: [PATCH 37/48] docs(net): a fill's per-group demand is what still waits for the group Fill::is_wanted and unwanted say whether a reader still waits for a group from the fill: once a reader takes an accepted group, the group's own demand says who reads it. Mirrored in moq-ffi, Go, Python, and Swift; Dart regenerated. Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 4 ++-- go/wrapper/publish.go | 7 ++++--- py/moq-rs/moq/publish.py | 6 +++--- rs/moq-ffi/src/producer.rs | 6 +++--- rs/moq-net/src/model/track/fill.rs | 10 ++++++---- swift/Sources/Moq/Dynamic.swift | 6 +++--- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 62be58375a4..bd83513ebcb 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -19686,7 +19686,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqfill_is_used() != 6429) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqfill_is_wanted() != 35921) { + if (uniffi_moq_ffi_checksum_method_moqfill_is_wanted() != 47156) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqfill_max_delay_us() != 40340) { @@ -19710,7 +19710,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqfill_unused() != 3173) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqfill_unwanted() != 58929) { + if (uniffi_moq_ffi_checksum_method_moqfill_unwanted() != 13637) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqgroupproducer_abort() != 59787) { diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 363e8189a43..26e802ee85f 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -446,13 +446,14 @@ func (f *Fill) Unused(ctx context.Context) error { return f.inner.Unused(ctx) } -// IsWanted reports whether a reader still reads group sequence: false once every -// reader narrowed past it, or nobody waits on the fill. +// IsWanted reports whether a reader still waits for group sequence from this fill: +// false once every reader took it or narrowed past it, or nobody waits on the fill. func (f *Fill) IsWanted(sequence uint64) bool { return f.inner.IsWanted(sequence) } -// Unwanted blocks until no reader reads group sequence, so stop serving it. +// Unwanted blocks until no reader waits for group sequence from this fill, so stop +// serving it. func (f *Fill) Unwanted(ctx context.Context, sequence uint64) error { return f.inner.Unwanted(ctx, sequence) } diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index ba7c2370ab0..89bc755c4b5 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -260,14 +260,14 @@ async def unused(self) -> None: await self._inner.unused() def is_wanted(self, sequence: int) -> bool: - """Whether a reader still reads group ``sequence``. + """Whether a reader still waits for group ``sequence`` from this fill. - False once every reader narrowed past it, or nobody waits on the fill. + False once every reader took it or narrowed past it, or nobody waits on the fill. """ return self._inner.is_wanted(sequence) async def unwanted(self, sequence: int) -> None: - """Wait until no reader reads group ``sequence``, so stop serving it.""" + """Wait until no reader waits for group ``sequence`` from this fill, so stop serving it.""" await self._inner.unwanted(sequence) diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index c245738b778..661adf0545a 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -565,13 +565,13 @@ impl MoqFill { .await } - /// Whether a reader still reads group `sequence`: false once every reader narrowed past - /// it, or nobody waits on the fill. + /// Whether a reader still waits for group `sequence` from this fill: false once every + /// reader took it or narrowed past it, or nobody waits on the fill. pub fn is_wanted(&self, sequence: u64) -> bool { self.inner.is_wanted(sequence) } - /// Wait until no reader reads group `sequence`, so stop serving it. + /// Wait until no reader waits for group `sequence` from this fill, so stop serving it. pub async fn unwanted(&self, sequence: u64) -> Result<(), MoqError> { let fill = self.inner.clone(); crate::ffi::detached(async move { diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index f3769ed1999..da7f46ab7c9 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -621,19 +621,21 @@ impl Fill { self.inner.result.poll_unused(waiter).map(|_| ()) } - /// Whether a reader still reads group `sequence`: false once every reader narrowed past - /// it, or nobody waits on the fill. A group stops being wanted for good. + /// Whether a reader still waits for group `sequence` from this fill: false once every + /// reader took it or narrowed past it, or nobody waits on the fill. A group stops being + /// wanted for good; once accepted and taken, [`group::Producer::demand`](crate::group::Producer::demand) + /// says who reads it. pub fn is_wanted(&self, sequence: u64) -> bool { self.inner.is_wanted(sequence) } - /// Wait until no reader reads group `sequence`, so stop serving it. + /// Wait until no reader waits for group `sequence` from this fill, so stop serving it. #[cfg(feature = "async")] pub async fn unwanted(&self, sequence: u64) { kio::wait(|waiter| self.poll_unwanted(sequence, waiter)).await } - /// Poll until no reader reads group `sequence`. + /// Poll until no reader waits for group `sequence` from this fill. pub fn poll_unwanted(&self, sequence: u64, waiter: &kio::Waiter) -> Poll<()> { self.inner.poll_unwanted(sequence, waiter) } diff --git a/swift/Sources/Moq/Dynamic.swift b/swift/Sources/Moq/Dynamic.swift index e5b70e36f3a..f31621b7536 100644 --- a/swift/Sources/Moq/Dynamic.swift +++ b/swift/Sources/Moq/Dynamic.swift @@ -107,13 +107,13 @@ public final class Fill: Sendable { try await ffi.unused() } - /// Whether a reader still reads group `sequence`: false once every reader narrowed past - /// it, or nobody waits on the fill. + /// Whether a reader still waits for group `sequence` from this fill: false once every + /// reader took it or narrowed past it, or nobody waits on the fill. public func isWanted(sequence: UInt64) -> Bool { ffi.isWanted(sequence: sequence) } - /// Suspend until no reader reads group `sequence`, so stop serving it. + /// Suspend until no reader waits for group `sequence` from this fill, so stop serving it. public func unwanted(sequence: UInt64) async throws { try await ffi.unwanted(sequence: sequence) } From c8354c52b9c95d6fd73516c45b07858c5529cf83 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 10:58:11 -0700 Subject: [PATCH 38/48] chore(lint): give the fill suppressions a reason Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 2 +- rs/moq-net/src/model/track/fill.rs | 25 ++++++++++++++----------- rs/moq-net/src/model/track/miss.rs | 5 ++++- rs/moq-transcode/src/rung.rs | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 17151e704d9..a7a27aca278 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -35,7 +35,7 @@ pub use fill::Fill; use fill::FillState; // private: capability gap -#[cfg_attr(not(feature = "async"), allow(unused_imports))] +#[cfg_attr(not(feature = "async"), allow(unused_imports, reason = "async-only"))] pub(crate) use fill::{GroupFill, Readers as FillReaders}; use std::{ diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index da7f46ab7c9..eaf74f362f6 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -125,7 +125,10 @@ impl Outcome { /// What was answered within `wanted`: accepted, or refused with why and the budget /// the fill asked with. - #[allow(clippy::type_complexity)] + #[expect( + clippy::type_complexity, + reason = "each answer pairs a run with its outcome; naming it would only move the type" + )] pub(super) fn within( &self, wanted: &RangeSet, @@ -681,7 +684,7 @@ impl Fill { /// Refuse every group still owed with `err`, which says nothing about whether the source /// has them, so nothing is recorded dropped. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) fn refuse(&self, err: Error) { let unresolved = self.inner.unresolved(self.inner.start.group..self.inner.end); @@ -689,7 +692,7 @@ impl Fill { } /// A handle on group `sequence` alone, for a session serving the fill one group at a time. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) fn group(&self, sequence: u64) -> GroupFill { GroupFill { @@ -815,7 +818,7 @@ impl Inner { /// Whether an [`Error::Old`] answer is the publisher's max age, which binds every reader, /// rather than this fill's own budget, which a more tolerant reader may exceed. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] fn old_binds_everyone(&self) -> bool { let Some(max_delay) = self.max_delay else { return true; @@ -825,7 +828,7 @@ impl Inner { } /// Refuse group `sequence` with `err`, as its source answered. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] fn reject(&self, sequence: u64, err: Error) { let Some(end) = sequence.checked_add(1) else { return; @@ -904,7 +907,7 @@ impl TrackState { } /// The readers of one group of a fill, outliving its [`GroupFill`]. -#[cfg_attr(not(feature = "async"), allow(dead_code))] +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) struct Readers { fills: kio::Shared, @@ -913,7 +916,7 @@ pub(crate) struct Readers { outcome: kio::Producer, } -#[cfg_attr(not(feature = "async"), allow(dead_code))] +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] impl Readers { /// Poll until no reader waits on the group. Once the fill ends its per-group demand goes /// with it, so then until none of its readers is left. @@ -933,7 +936,7 @@ impl Readers { /// One group of a [`Fill`], for a session serving it a group at a time. Answering it answers /// that group of the fill; dropping it unanswered leaves the group to the fill. -#[cfg_attr(not(feature = "async"), allow(dead_code))] +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) struct GroupFill { inner: Arc, @@ -941,7 +944,7 @@ pub(crate) struct GroupFill { frame_start: u64, } -#[cfg_attr(not(feature = "async"), allow(dead_code))] +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] impl GroupFill { /// The group sequence to fetch. // private: capability gap @@ -1006,7 +1009,7 @@ impl GroupFill { } /// The track the fill serves. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) fn track(&self) -> kio::Producer { self.inner.state.clone() @@ -1027,7 +1030,7 @@ impl GroupFill { /// Declare the track's exclusive final sequence, as the source answering this group /// reported it. A no-op once the track declared an end, or holds a later group. - #[cfg_attr(not(feature = "async"), allow(dead_code))] + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] // private: capability gap pub(crate) fn finish_track_at(&self, final_sequence: u64) { if let Ok(mut state) = TrackState::modify(&self.inner.state) diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 611c0d1be2a..3c2a8ba1fa4 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -469,7 +469,10 @@ impl TrackState { /// Never: accounted for by its arrivals (evicted, or a gap it skipped), or in a gap its /// grace gave up on, or anything once the feed is over. A declared start is no promise: /// a group below it may still arrive. - #[allow(clippy::type_complexity)] + #[expect( + clippy::type_complexity, + reason = "the never-fed runs and the awaited gaps are returned together; naming them would only move the type" + )] pub(super) fn unfed( &self, hole: ops::Range, diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index ee230abc203..f4c6c24cde7 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -895,7 +895,7 @@ mod tests { /// A group every reader narrowed past releases the fill's one-at-a-time fetch, so the /// groups the fill still owes are fetched rather than waiting behind it. #[tokio::test(start_paused = true)] - #[allow(clippy::disallowed_methods)] + #[expect(clippy::disallowed_methods, reason = "tokio's paused clock bounds the waits")] async fn a_group_nobody_reads_releases_its_fill() { use std::task::Poll; let (rung, source, _retire) = idle_rung(); From 5f0d1175caaf2a82b5bf94e214758e101a434a19 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 13:11:34 -0700 Subject: [PATCH 39/48] fix(net): a fill starts at the frame of its own range A reader with disjoint ranges asked every fill from the lowest start of its ranges, so a miss in a later range that starts mid-group asked frame 0. Each run's first group now takes its frame from the reader's resolved window. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 39 ++++++++++++++++++++++++++++-- rs/moq-net/src/model/track/miss.rs | 14 +++++------ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index a7a27aca278..8d24784926b 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5943,17 +5943,17 @@ impl Cursor { /// Ask fills for what this cursor misses from `from`, and take in what they answered, /// parking `waiter` on the fills still out. Whatever it gives up goes to the drop cursor. fn probe(&mut self, from: u64, drift: &Drift, waiter: &kio::Waiter) { + let (window, cap, revision) = (&self.filter.window, self.end_sequence, self.filter.revision); let asking = { let subscription = self.subscription.read(); miss::Asking { - start: subscription.start(), + window, priority: subscription.priority, max_delay: subscription.max_delay, order: subscription.order, } }; let fills = self.state.read().fills.clone(); - let (window, cap, revision) = (&self.filter.window, self.end_sequence, self.filter.revision); let misses = self.misses.get_or_insert_with(Box::default); let mut given_up = Vec::new(); if misses.lowest().is_some() { @@ -16544,6 +16544,41 @@ mod test { assert_eq!((fill.start(), fill.end()), (Position { group: 1, frame: 0 }, 4)); } + /// Each fill starts at the frame its own range starts from, not the lowest start of + /// the reader's ranges. + #[test] + fn a_fill_starts_at_its_own_ranges_frame() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let at = |group, frame| Anchor::Absolute(Position { group, frame }); + let mut reader = producer.subscribe(replay().with_ranges([ + Range { + start: at(0, 0), + end: Some(at(1, 0)), + }, + Range { + start: at(5, 7), + end: None, + }, + ])); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let _ = reader.poll_recv_group(&kio::Waiter::noop()); + + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) { + asked.push((fill.start(), fill.end())); + } + asked.sort(); + assert_eq!( + asked, + [ + (Position { group: 0, frame: 0 }, 1), + (Position { group: 5, frame: 7 }, 8) + ] + ); + } + /// A descending reader joining a queued fill makes it newest first, as any descending /// subscriber does a subscription. #[test] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 3c2a8ba1fa4..2cda0eb9ce9 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -26,11 +26,12 @@ use rangemap::{RangeInclusiveSet, RangeSet}; use super::fill::{Ask, FillState, Join}; use super::{Drift, Order, Position, TrackState}; use crate::Error; +use crate::model::subscription::Window; /// What one reader asks its fills with. -pub(super) struct Asking { - /// Where the reader's own start falls, for the frame of a run beginning at its group. - pub start: Option, +pub(super) struct Asking<'a> { + /// The reader's resolved ranges, for the frame each run's first group starts from. + pub window: &'a Window, pub priority: i8, pub max_delay: Option, pub order: Order, @@ -383,7 +384,7 @@ impl Misses { state: &kio::Consumer, fills: &kio::Shared, runs: &RangeSet, - asking: &Asking, + asking: &Asking<'_>, given_up: &mut GivenUp, ) { let mut runs: Vec> = runs.iter().cloned().collect(); @@ -391,10 +392,7 @@ impl Misses { runs.reverse(); } for run in runs { - let frame = asking - .start - .filter(|start| start.group == run.start) - .map_or(0, |start| start.frame); + let (frame, _) = asking.window.frames(run.start); let ask = Ask { start: Position { group: run.start, From f44105284da67e100f6909a9782fd13a956f85c0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 13:21:20 -0700 Subject: [PATCH 40/48] fix(net): a range starting mid-group heads its own fill Adjacent ranges coalesce into one run, so a range starting inside the group after another range was read whole as a later group of one fill. Runs now split at every group a range starts inside, past its frame 0. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/subscription/window.rs | 11 +++++++ rs/moq-net/src/model/track.rs | 35 +++++++++++++++++++++ rs/moq-net/src/model/track/miss.rs | 15 ++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 8bd57e87b85..fc50ec0415a 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -527,6 +527,17 @@ impl Window { hull.unwrap_or((0, None)) } + /// The groups some range starts inside, past their frame 0, that are read from there. + pub fn partial_starts(&self) -> impl Iterator + '_ { + self.ranges + .iter() + .filter_map(|range| match range.start { + Anchor::Absolute(start) if start.frame > 0 => Some(start.group), + _ => None, + }) + .filter(|group| self.frames(*group).0 > 0) + } + /// The first run of `range` that is in range. pub fn clip(&self, range: ops::Range) -> Option> { let last = range.end.checked_sub(1).filter(|last| *last >= range.start)?; diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 8d24784926b..f42fd096218 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -16579,6 +16579,41 @@ mod test { ); } + /// A range starting inside the group after another range heads its own fill, rather + /// than being read whole as the second group of one. + #[test] + fn an_adjacent_range_starting_mid_group_heads_its_own_fill() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let at = |group, frame| Anchor::Absolute(Position { group, frame }); + let mut reader = producer.subscribe(replay().with_ranges([ + Range { + start: at(4, 0), + end: Some(at(5, 0)), + }, + Range { + start: at(5, 7), + end: None, + }, + ])); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let _ = reader.poll_recv_group(&kio::Waiter::noop()); + + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) { + asked.push((fill.start(), fill.end())); + } + asked.sort(); + assert_eq!( + asked, + [ + (Position { group: 4, frame: 0 }, 5), + (Position { group: 5, frame: 7 }, 8) + ] + ); + } + /// A descending reader joining a queued fill makes it newest first, as any descending /// subscriber does a subscription. #[test] diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index 2cda0eb9ce9..c4caa55f7aa 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -387,7 +387,20 @@ impl Misses { asking: &Asking<'_>, given_up: &mut GivenUp, ) { - let mut runs: Vec> = runs.iter().cloned().collect(); + // A group a range starts inside heads its own fill, since a fill reads every group + // after its first whole. + let mut cuts: Vec = asking.window.partial_starts().collect(); + cuts.sort_unstable(); + cuts.dedup(); + let mut runs: Vec> = runs + .iter() + .flat_map(|run| { + let inside = cuts.iter().copied().filter(|cut| run.start < *cut && *cut < run.end); + let starts = std::iter::once(run.start).chain(inside.clone()); + let ends = inside.chain(std::iter::once(run.end)); + starts.zip(ends).map(|(start, end)| start..end) + }) + .collect(); if asking.order == Order::Descending { runs.reverse(); } From 4fac8e28fb8d239eafcec4deec2732e4a0c83d49 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 13:49:45 -0700 Subject: [PATCH 41/48] quest: plan an errored end that keeps history; list archive time ranges in m2 Repoint the quests main added against the archive consumer this PR lands. Co-Authored-By: Claude Opus 5.5 --- quest/m1/api-audit/README.md | 8 ++---- quest/m1/api-audit/media-trim.md | 5 ++-- quest/m1/api-audit/requests.md | 1 - quest/m1/compress/migrate.md | 6 ++-- quest/m1/release-audit/README.md | 1 - quest/m2/README.md | 2 ++ quest/m2/archive-time-ranges.md | 5 ++++ quest/m2/errored-end.md | 41 +++++++++++++++++++++++++++ quest/m2/performance-survey/README.md | 5 ++-- 9 files changed, 57 insertions(+), 17 deletions(-) create mode 100644 quest/m2/errored-end.md diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index 33766fe4a58..ba4069e5f91 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -41,11 +41,9 @@ Rules every child follows: - Each child lists its public API and wire impact in its PR and writes its own `MIGRATE.md` entries. -Already gated elsewhere, so not children: the archive `Producer` and `Reader` -disagree on layout while both claim version 2 -([archive consumer](/quest/m1/archive/consumer.md)), and the binary-to-flate -fold is followed by flate's removal ([compress migrate](/quest/m1/compress/migrate.md)). -Both are in m1, so the cut waits for them. hang's +Already gated elsewhere, so not a child: the binary-to-flate fold is followed +by flate's removal ([compress migrate](/quest/m1/compress/migrate.md)), in m1, +so the cut waits for it. hang's `Archive::replay_epoch` goes with the root `archive` section in [catalog HLS](/quest/m1/archive/catalog-hls.md). diff --git a/quest/m1/api-audit/media-trim.md b/quest/m1/api-audit/media-trim.md index 4e85c7fc329..8336e0a50b5 100644 --- a/quest/m1/api-audit/media-trim.md +++ b/quest/m1/api-audit/media-trim.md @@ -21,10 +21,9 @@ Decided 2026-10-22 (API audit). Make `pub(crate)` or delete: sets history, so `?start=` always answers 416 while `doc/bin/hls.md` documents it. Remove the docs with it; see [history floor](/quest/m1/archive/history-floor.md). -- moq-archive `segment::*`, `Key`, and most of `Store`. - moq-json `window::Checkpoint`, `Encoder::resume`, `Producer::{resume, cut}` - and `moq_mux::timeline::Producer::resume`, reached only by tests, unless - the [archive consumer](/quest/m1/archive/consumer.md) needs them. + and `moq_mux::timeline::Producer::resume`, reached only by tests; the + archive consumer does not use them. - hang `Catalog::from_value`, `Section`/`deserialize_section`. - The `@moq/loc` `Producer` (test-only, no Rust twin) and the JS `Container.Consumer` `maxAge` guard. diff --git a/quest/m1/api-audit/requests.md b/quest/m1/api-audit/requests.md index fdad1be8405..b63c6d63bc8 100644 --- a/quest/m1/api-audit/requests.md +++ b/quest/m1/api-audit/requests.md @@ -28,4 +28,3 @@ Public API: moq-net, `@moq/net`, moq-ffi and wrappers renamed. Wire: none. ## Related - [Claim request type](/quest/m1/api-audit/claim-request-type.md) - reshapes the origin claim this renames -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - adds `requested_fill` to the track handle this renames diff --git a/quest/m1/compress/migrate.md b/quest/m1/compress/migrate.md index 76915941907..e69de9e1881 100644 --- a/quest/m1/compress/migrate.md +++ b/quest/m1/compress/migrate.md @@ -16,9 +16,8 @@ See the [epic](/quest/m1/compress/README.md) for the decisions. Decided 2026-10-20 (maintainer): the timeline is `.hls` with `compress: true`, never `.hls.z`. [Catalog HLS field](/quest/m1/archive/catalog-hls.md) -renames it and publishes it uncompressed, and the -[archive consumer](/quest/m1/archive/consumer.md) replaces the CLI's archive -reader, so this quest lands after both and only sets the flag. +renames it and publishes it uncompressed, and the archive consumer replaced +the CLI's archive reader, so this quest lands after it and only sets the flag. - Delete the `.z` names outright, with no transition release: hangz is opt-in and `main` takes breaking changes. Add the MIGRATE.md entries. @@ -61,4 +60,3 @@ Public API: removals listed above. Wire: the hang catalog loses ## Required - [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - renames the timeline to `.hls`, which this compresses -- [Archive consumer](/quest/m1/archive/consumer.md) - replaces the CLI archive reader that reads `.z` timelines diff --git a/quest/m1/release-audit/README.md b/quest/m1/release-audit/README.md index 42a06c9fc67..5dbe548e71a 100644 --- a/quest/m1/release-audit/README.md +++ b/quest/m1/release-audit/README.md @@ -110,7 +110,6 @@ child records whether its behavior is new on main or inherited from release. - [OBS video source](/quest/m2/obs-moq-video/source.md) - reproduced inherited timing and EOF defects remain with the decoder replacement owner - [QPACK dependency fix](/quest/m0/web-transport-proto-0.6.3.md) - decoded header expansion is already tracked - [Compressed tracks](/quest/m1/compress/README.md) - mid-group widening and application migration have existing owners -- [Archive consumer](/quest/m1/archive/consumer.md) - current producer and CLI reader still need the new-format roundtrip - [Measured release performance](/quest/m1/release-perf/README.md) - existing paired benchmark regressions and their confirmation runs - [Transport performance audit](/quest/m2/audit-perf.md) - congestion-control coverage remains outstanding - [Binding timestamps](/quest/m1/binding-timestamps.md) - exact timestamps across language APIs diff --git a/quest/m2/README.md b/quest/m2/README.md index d39d16e5709..047ba0bfa95 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -202,7 +202,9 @@ ranked just below deterministic TS legs, which it requires. - [QUIC on the sim](/quest/m2/quic-sim.md) - MoQ sessions and the relay run over `moq-quic` on the seeded sim, reusing its `Pair` and `Link` - [Seeded sim](/quest/m2/sim-seed.md) - sim tests draw every jitter from one seeded generator and replay bit-exact +- [Archive time ranges](/quest/m2/archive-time-ranges.md) - a `Time(pts)` range on an archive consumer broadcast resolves from the archive's keys, however old - [DVR rewind](/quest/m2/archive-dvr.md) - follow-on capability after the release contract is stable +- [An errored track end keeps its history](/quest/m2/errored-end.md) - a replayed archive ends with its source's error after serving every stored group, instead of a clean finish - [JS HLS field](/quest/m2/js-hls.md) - follow-on capability after the release contract is stable diff --git a/quest/m2/archive-time-ranges.md b/quest/m2/archive-time-ranges.md index 1a548314354..88096dfdb80 100644 --- a/quest/m2/archive-time-ranges.md +++ b/quest/m2/archive-time-ranges.md @@ -9,6 +9,11 @@ it, however old. ## Plan +Decided 2026-10-23 (maintainer, on +[moq-dev/moq#6262](https://github.com/moq-dev/moq/pull/6262)): this stays in +m2, required by [DVR rewind](/quest/m2/archive-dvr.md), rather than blocking +the archive consumer, which ships without time anchors. + The consumer creates each track live from its newest stored group and serves older groups through `track::Fill`, so its cache holds almost nothing a time anchor can resolve against: today a `Time(pts)` start older than the newest diff --git a/quest/m2/errored-end.md b/quest/m2/errored-end.md new file mode 100644 index 00000000000..3f31c7c4c0b --- /dev/null +++ b/quest/m2/errored-end.md @@ -0,0 +1,41 @@ +# [M] An errored track end keeps its history + +## Goal + +A track can end with an error and still serve the groups it holds or can fill: +a reader arriving later gets that history, then the error, instead of either +the error alone or a clean finish. `archive::Consumer` replays a stored +`{"error":code}` end this way, so a replayed recording ends the way its source +did. + +## Plan + +From [moq-dev/moq#6262](https://github.com/moq-dev/moq/pull/6262), decided by +the maintainer 2026-10-23: the consumer replays an errored end as a clean +finish after its newest stored group, plus a warn log, because +`track::Producer::abort` refuses every later reader the stored history. The +alternatives were an abort (loses history) and an abort only after the last +group (still refuses later readers). This quest replaces the clean finish. + +- `moq-net`: an end that records an error alongside the final position. + Readers below the end read groups and fills as after `finish_at`; a reader + reaching the end gets the error instead of `None`. Unlike `abort`, the cache, + fills, and the listing stay as after a finish. Pick the API shape in the PR + with options and a recommendation, such as `finish_at` taking an end reason, + and no `finish_with_x` variant. +- Carry it across a hop if a relay must forward it: moq-lite resets the + subscription with the code today, which a later subscriber never sees. Any + wire change goes in the next unpublished lite version and its draft. +- `archive::Consumer` maps `End::Error(code)` to the new end and drops its + warn log. + +Test a consumer replaying an errored track: a subscriber from the oldest group +reads every stored group and then the error; a later fetch still reads stored +groups. + +Public API: a `track::Producer` end carrying an error, and what a reader +observes at it. Wire: possibly a lite track end carrying an error code. + +## Related + +- [DVR rewind](/quest/m2/archive-dvr.md) - seeks back through an archive whose source may have ended with an error diff --git a/quest/m2/performance-survey/README.md b/quest/m2/performance-survey/README.md index d5452ef2911..0b02d4080bb 100644 --- a/quest/m2/performance-survey/README.md +++ b/quest/m2/performance-survey/README.md @@ -166,9 +166,8 @@ No duplicate quest or workflow edit belongs here. `Unlimited`; their numbers cannot establish controller performance. Expand reassembly flight/reordering coverage there before proposing another index. [BBR study](/quest/m2/quic-bbr-natural-drain.md) owns controller behavior. -- Archive reader internals are being replaced by the - [archive consumer](/quest/m1/archive/consumer.md). Do not optimize the - outgoing timeline reader. Benchmark the landed replacement when revisiting it. +- The archive consumer replaced the timeline reader; benchmark it when + revisiting archive reads. ## Required From 0697579a1da800501f07e3de7bb621a92cadd479 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 14:13:59 -0700 Subject: [PATCH 42/48] style: rustfmt the archive tests Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/consumer/tests.rs | 5 ++++- rs/moq-hls/src/export/archive_tests.rs | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index bf987c74e9c..b99a8ab3e0d 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -331,7 +331,10 @@ async fn a_narrowed_read_skips_the_group_it_left() { subscriber.update(ascending(1..)).unwrap(); let _ = subscriber.poll_recv_group(&waiter); - assert!(object_read(&mock, 1).await, "group 1 waited behind group 0, which nobody reads"); + assert!( + object_read(&mock, 1).await, + "group 1 waited behind group 0, which nobody reads" + ); gate.add_permits(16); } diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs index c620b5e2b53..bafdb50ce71 100644 --- a/rs/moq-hls/src/export/archive_tests.rs +++ b/rs/moq-hls/src/export/archive_tests.rs @@ -150,9 +150,7 @@ impl Recording { let media = broadcast.create_track(name, media).unwrap(); let timeline = track::Info::default().with_timescale(Timescale::MILLI); let timeline = broadcast.create_track(timeline_name(name), timeline).unwrap(); - let mut config = window::ProducerConfig::default() - .with_compression(true) - .with_ops(false); + let mut config = window::ProducerConfig::default().with_compression(true).with_ops(false); if let Some(limit) = checkpoint { config = config.with_checkpoint_records(limit); } From bf73de7ada2a1d497ed2517d1f9f6d39c4e97d4f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 22:20:37 -0700 Subject: [PATCH 43/48] fix(archive): adapt the consumer to whole-group ranges and the trimmed archive surface Main made every application fetch start at a group's first frame (#6465) and trimmed moq-archive's public surface (#6517). The consumer tests fetch whole groups, `Store` keeps only `new` and `prefix` public, and the moq-hls archive tests drop the history and range playlists main removed. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + rs/moq-archive/src/consumer/tests.rs | 58 ++++++------- rs/moq-archive/src/lib.rs | 2 +- rs/moq-archive/src/store.rs | 7 +- rs/moq-cli/MIGRATE.md | 2 +- rs/moq-hls/src/export/archive_tests.rs | 111 +++++++------------------ 6 files changed, 62 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a15c8867ae..427d528baa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4923,6 +4923,7 @@ dependencies = [ name = "moq-hls" version = "0.5.11" dependencies = [ + "async-trait", "axum", "bytes", "futures", @@ -4930,6 +4931,7 @@ dependencies = [ "humantime", "kio 0.6.2", "m3u8-rs", + "moq-archive", "moq-clock", "moq-json", "moq-mux", diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index b99a8ab3e0d..32835f45133 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -117,16 +117,10 @@ async fn serve(store: &Store) -> (broadcast::Producer, JoinHandle moq_net::Result> { +/// FETCH group `sequence`. +async fn fetch(broadcast: &broadcast::Producer, track: &str, sequence: u64) -> moq_net::Result> { let track = broadcast.consume().track(track)?; - let fetch = group::Fetch::default().with_frame_start(frame_start); - let mut group = timeout(Duration::from_secs(10), track.fetch_group(sequence, fetch)) + let mut group = timeout(Duration::from_secs(10), track.fetch_group(sequence, None)) .await .expect("the fetch is answered")?; let mut frames = Vec::new(); @@ -143,7 +137,7 @@ async fn fetch( async fn subscribe(broadcast: &broadcast::Producer, track: &str) -> track::Ordered { let track = broadcast.consume().track(track).unwrap(); let subscription = Subscription::default() - .with_groups(0..) + .with_range(0..) .with_order(Order::Ascending) .with_max_delay(None); track.subscribe(subscription).await.unwrap().ordered() @@ -248,9 +242,9 @@ async fn a_recording_round_trips() { // The untimed track keeps its datagram a datagram. let info = broadcast.consume().track("log").unwrap().query().await.unwrap(); assert_eq!((info.priority, info.timescale), (9, None)); - assert_eq!(fetch(&broadcast, "log", 0, 0).await.unwrap(), ["a"]); + assert_eq!(fetch(&broadcast, "log", 0).await.unwrap(), ["a"]); assert!(matches!( - fetch(&broadcast, "log", 1, 0).await, + fetch(&broadcast, "log", 1).await, Err(moq_net::Error::NotFetchable | moq_net::Error::NotFound) )); } @@ -265,13 +259,12 @@ async fn history_older_than_any_max_age_is_served() { archive.object("video", 0, vec![frames(true, 10_000, 0..2)]).await; let (broadcast, _run) = serve(&archive.store).await; - assert_eq!(fetch(&broadcast, "video", 0, 0).await.unwrap(), expected(true, 0, 0..2)); + assert_eq!(fetch(&broadcast, "video", 0).await.unwrap(), expected(true, 0, 0..2)); let mut reader = subscribe(&broadcast, "video").await; assert_eq!(next(&mut reader).await.unwrap(), (0, expected(true, 0, 0..2))); } -/// One GET serves every group of an object, and a group split across objects is served whole, -/// from any frame. +/// One GET serves every group of an object, and a group split across objects is served whole. #[tokio::test(start_paused = true)] async fn groups_come_from_cached_objects_and_span_them() { let archive = Archive::new(); @@ -289,13 +282,11 @@ async fn groups_come_from_cached_objects_and_span_them() { let (broadcast, _run) = serve(&archive.store).await; let mock = archive.store.inner().clone(); mock.take(); - assert_eq!(fetch(&broadcast, "video", 0, 0).await.unwrap(), expected(true, 0, 0..2)); - assert_eq!(fetch(&broadcast, "video", 1, 1).await.unwrap(), expected(true, 1, 1..2)); + assert_eq!(fetch(&broadcast, "video", 0).await.unwrap(), expected(true, 0, 0..2)); + assert_eq!(fetch(&broadcast, "video", 1).await.unwrap(), expected(true, 1, 0..2)); assert_eq!(mock.gets().len(), 1, "both groups come from one object"); - assert_eq!(fetch(&broadcast, "video", 2, 0).await.unwrap(), expected(true, 2, 0..5)); - // From a frame in the continuation only. - assert_eq!(fetch(&broadcast, "video", 2, 4).await.unwrap(), expected(true, 2, 4..5)); + assert_eq!(fetch(&broadcast, "video", 2).await.unwrap(), expected(true, 2, 0..5)); } /// A group every reader narrowed past stops its GET, so the groups the fill still owes are read @@ -315,7 +306,7 @@ async fn a_narrowed_read_skips_the_group_it_left() { let ascending = |groups| { Subscription::default() .with_max_delay(None) - .with_groups(groups) + .with_range(groups) .with_order(Order::Ascending) }; let mut subscriber = broadcast @@ -363,9 +354,9 @@ async fn a_gap_reads_as_dropped() { let (broadcast, _run) = serve(&archive.store).await; // Inside an object, and between two. - not_found(fetch(&broadcast, "video", 1, 0).await); - not_found(fetch(&broadcast, "video", 3, 0).await); - not_found(fetch(&broadcast, "video", 4, 0).await); + not_found(fetch(&broadcast, "video", 1).await); + not_found(fetch(&broadcast, "video", 3).await); + not_found(fetch(&broadcast, "video", 4).await); let mut reader = subscribe(&broadcast, "video").await; for expected in [0, 2, 5, 6] { @@ -386,15 +377,14 @@ async fn a_pruned_start_reads_as_dropped() { archive.object("video", 0, vec![frames(true, 8, 0..1)]).await; let (broadcast, _run) = serve(&archive.store).await; - not_found(fetch(&broadcast, "video", 0, 0).await); - not_found(fetch(&broadcast, "video", 4, 0).await); - not_found(fetch(&broadcast, "video", 5, 0).await); - assert_eq!(fetch(&broadcast, "video", 5, 2).await.unwrap(), expected(true, 5, 2..4)); - assert_eq!(fetch(&broadcast, "video", 6, 0).await.unwrap(), expected(true, 6, 0..2)); + not_found(fetch(&broadcast, "video", 0).await); + not_found(fetch(&broadcast, "video", 4).await); + not_found(fetch(&broadcast, "video", 5).await); + assert_eq!(fetch(&broadcast, "video", 6).await.unwrap(), expected(true, 6, 0..2)); // An object pruned after it was listed reads as dropped too. archive.store.inner().hide_gets("rec/video/0000000000000000007-"); - not_found(fetch(&broadcast, "video", 7, 0).await); + not_found(fetch(&broadcast, "video", 7).await); } /// A growing archive is followed through each track's own cursor, a key sorting before @@ -527,7 +517,7 @@ async fn groups_and_datagrams_keep_their_forms() { let (broadcast, _run) = serve(&archive.store).await; let track = broadcast.consume().track("mixed").unwrap(); let subscription = Subscription::default() - .with_groups(0..) + .with_range(0..) .with_order(Order::Ascending) .with_max_delay(None); let mut reader = track.subscribe(subscription).await.unwrap(); @@ -543,9 +533,9 @@ async fn groups_and_datagrams_keep_their_forms() { assert_eq!((datagram.sequence, &datagram.payload[..]), (3, &b"3/0"[..])); // A stored datagram is never fetched as a group. - assert!(fetch(&broadcast, "mixed", 1, 0).await.is_err()); - assert_eq!(fetch(&broadcast, "mixed", 0, 0).await.unwrap(), ["0/0"]); - assert_eq!(fetch(&broadcast, "mixed", 2, 0).await.unwrap(), ["2/0"]); + assert!(fetch(&broadcast, "mixed", 1).await.is_err()); + assert_eq!(fetch(&broadcast, "mixed", 0).await.unwrap(), ["0/0"]); + assert_eq!(fetch(&broadcast, "mixed", 2).await.unwrap(), ["2/0"]); // The newest stored group arrives live as a group, still open. let mut sequences = Vec::new(); diff --git a/rs/moq-archive/src/lib.rs b/rs/moq-archive/src/lib.rs index a26b55fec5d..f50a4a1426e 100644 --- a/rs/moq-archive/src/lib.rs +++ b/rs/moq-archive/src/lib.rs @@ -69,7 +69,7 @@ mod mock; mod path; pub mod producer; /// The object store and key prefix an archive lives under. -pub mod store; +mod store; pub use consumer::Consumer; pub use error::{Error, Result}; diff --git a/rs/moq-archive/src/store.rs b/rs/moq-archive/src/store.rs index 6b01cf01167..8a9196ac40d 100644 --- a/rs/moq-archive/src/store.rs +++ b/rs/moq-archive/src/store.rs @@ -23,7 +23,7 @@ impl Store { } /// The underlying object store. - pub fn inner(&self) -> &T { + pub(crate) fn inner(&self) -> &T { &self.inner } @@ -36,7 +36,7 @@ impl Store { /// /// An existing recording in an older format is refused with [`Error::Version`]: a /// [`Consumer`](crate::Consumer) still reads it, but nothing appends to it. - pub async fn put_info(&self, track: &str, info: &Info) -> Result<()> { + pub(crate) async fn put_info(&self, track: &str, info: &Info) -> Result<()> { let path = self.info_path(track)?; let bytes = info.encode()?; match self @@ -69,7 +69,8 @@ impl Store { } /// Fetch and validate a track's `.info`. - pub async fn get_info(&self, track: &str) -> Result { + #[cfg(test)] + pub(crate) async fn get_info(&self, track: &str) -> Result { Info::decode(&self.get_bytes(&self.info_path(track)?).await?) } diff --git a/rs/moq-cli/MIGRATE.md b/rs/moq-cli/MIGRATE.md index 84620196579..92a92ed3cce 100644 --- a/rs/moq-cli/MIGRATE.md +++ b/rs/moq-cli/MIGRATE.md @@ -28,7 +28,7 @@ Add `--passthrough` to keep publishing CMAF. `moq export archive ` records every broadcast announced at or under `--broadcast`, each instance under `//@`, until the connection ends, instead of one broadcast at ``. Point `` at the root you want recordings under, and drop `--catalog-format`, which it now refuses. Untimed tracks record, and `--retention` works on them. -`import archive` does not read the new layout yet; it still replays recordings made by older `moq` versions. +`import archive` replays one recorded broadcast in the new layout, `//@`, and no longer reads recordings made by older `moq` versions. ### `--cluster-lan-secret` is a file path only (#5910) diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs index 76d4fe9ec04..3ddd9a405ef 100644 --- a/rs/moq-hls/src/export/archive_tests.rs +++ b/rs/moq-hls/src/export/archive_tests.rs @@ -12,14 +12,13 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use bytes::{Bytes, BytesMut}; -use futures::TryStreamExt; use futures::stream::BoxStream; use hang::timeline::{Position, Record}; use moq_archive::Store; use moq_archive::object_store::memory::InMemory; use moq_archive::object_store::path::Path; use moq_archive::object_store::{ - self, CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, ObjectStoreExt, + self, CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult, }; use moq_json::window; @@ -119,6 +118,8 @@ struct Source { /// A source broadcast recorded into a store as it publishes. struct Recording { + /// The store's own handle, for its GET log. + counting: Counting, store: Store, broadcast: moq_net::broadcast::Producer, catalog: moq_json::snapshot::Producer, @@ -127,9 +128,9 @@ struct Recording { } impl Recording { - /// `archive` is the catalog's root entry; `checkpoint` bounds each timeline's checkpoint. - async fn new(archive: hang::catalog::Archive, checkpoint: Option) -> Self { - let store = Store::new(Counting::default(), "rec"); + async fn new() -> Self { + let counting = Counting::default(); + let store = Store::new(counting.clone(), "rec"); let broadcast = moq_net::broadcast::Info::new().produce(); let track = broadcast .create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info()) @@ -138,7 +139,7 @@ impl Recording { json.deltas = false; let mut catalog = moq_json::snapshot::Producer::new(track, json); catalog - .update(moq_net::Timed::from(&self::catalog(archive)).at(Timestamp::now())) + .update(moq_net::Timed::from(&self::catalog()).at(Timestamp::now())) .unwrap(); let mut sources = HashMap::new(); @@ -150,10 +151,7 @@ impl Recording { let media = broadcast.create_track(name, media).unwrap(); let timeline = track::Info::default().with_timescale(Timescale::MILLI); let timeline = broadcast.create_track(timeline_name(name), timeline).unwrap(); - let mut config = window::ProducerConfig::default().with_compression(true).with_ops(false); - if let Some(limit) = checkpoint { - config = config.with_checkpoint_records(limit); - } + let config = window::ProducerConfig::default().with_compression(true).with_ops(false); let source = Source { media, timeline, @@ -177,6 +175,7 @@ impl Recording { } } Self { + counting, store, broadcast, catalog, @@ -257,7 +256,7 @@ impl Recording { /// Every GET since the last call. fn gets(&self) -> Vec { - self.store.inner().take() + self.counting.take() } } @@ -281,8 +280,9 @@ fn legacy(track: &str, micros: u64, keyframe: bool) -> Bytes { encoded.freeze() } -/// A live-style archive entry naming every rendition's timeline. -fn live() -> hang::catalog::Archive { +/// The source's catalog: every rendition's config, plus an `archive` entry naming each +/// rendition's timeline. Out-of-band configs, so no init needs media. +fn catalog() -> hang::Catalog { let mut archive = hang::catalog::Archive::new(); for track in ["360p", "1080p", "audio"] { archive.timelines.insert( @@ -290,20 +290,6 @@ fn live() -> hang::catalog::Archive { hang::catalog::Timeline::new(timeline_name(track), 4_000), ); } - archive -} - -/// The archive entry of a source that declares itself durable: its timelines, in a store. -fn durable() -> hang::catalog::Archive { - let mut archive = live(); - archive.store = Some("memory:///rec/".parse().unwrap()); - archive.version = Some(hang::catalog::Archive::VERSION); - archive -} - -/// The source's catalog: every rendition's config, plus `archive`. Out-of-band configs, so no -/// init needs media. -fn catalog(archive: hang::catalog::Archive) -> hang::Catalog { let mut catalog = hang::Catalog::default(); catalog.archive = Some(archive); for (name, width, height) in [("360p", 640, 360), ("1080p", 1920, 1080)] { @@ -320,17 +306,14 @@ fn catalog(archive: hang::catalog::Archive) -> hang::Catalog { /// An archive served back through an origin by a consumer, and a broadcaster exporting it. struct Replay { broadcaster: Arc, - origin: moq_net::origin::Producer, + /// Keeps the served broadcast's origin open. + _origin: moq_net::origin::Producer, consumer: tokio::task::JoinHandle>, driver: tokio::task::JoinHandle, } impl Replay { async fn open(store: &Store, cache: u64) -> Self { - Self::open_with(store, cache, Config::default()).await - } - - async fn open_with(store: &Store, cache: u64, config: Config) -> Self { let (origin, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); let driver = tokio::spawn(moq_net::time::run(driver)); let broadcast = origin.create_broadcast("rec").unwrap(); @@ -341,14 +324,14 @@ impl Replay { let consumer = tokio::spawn(consumer.run()); broadcast.announce(Default::default()).unwrap(); - let broadcaster = Broadcaster::new(crate::resolved(&origin, "rec").await, config); + let broadcaster = Broadcaster::new(crate::resolved(&origin, "rec").await, Config::default()); tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()) .await .expect("a rendition becomes playable or fails"); Self { broadcaster, - origin, + _origin: origin, consumer, driver, } @@ -380,22 +363,6 @@ impl Replay { } panic!("{name} playlist never became ready"); } - - /// Wait until `name`'s playlist answers a failed timeline, then return that error. - async fn failure(&self, kind: Kind, name: &str) -> crate::Error { - let rendition = self.rendition(kind, name); - for _ in 0..500 { - let answer = tokio::time::timeout(Duration::from_secs(5), rendition.playlist(None)) - .await - .expect("playlist answers"); - if let Err(err) = answer { - assert!(matches!(err, crate::Error::TimelineFailed { .. }), "{name}: {err}"); - return err; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("{name} playlist never failed"); - } } impl Drop for Replay { @@ -412,29 +379,10 @@ fn is_media(path: &str) -> bool { !marker && !path.contains("timeline") && !path.contains("catalog") } -/// The keys of `track`'s stored objects, oldest first, with the first group each holds. -async fn objects(store: &Store, track: &str) -> Vec<(u64, Path)> { - // The names here escape only `.`, as a track directory spells it. - let encoded = track.replace('.', "%2E"); - let dir = Path::parse(format!("rec/{encoded}")).unwrap(); - let metas: Vec = store.inner().list(Some(&dir)).try_collect().await.unwrap(); - let mut objects: Vec<(u64, Path)> = metas - .into_iter() - .filter_map(|meta| { - let name = meta.location.filename()?; - let group = name.split('-').next()?.parse().ok()?; - Some((group, meta.location)) - }) - .collect(); - objects.sort(); - objects -} - /// `count` 2s segments: one keyframe group per video record, four audio groups per audio record. -/// The first video rendition by name, 1080p, is the reference. `checkpoint` bounds each -/// timeline's checkpoint. -async fn segments(count: u64, checkpoint: Option) -> Recording { - let mut recording = Recording::new(durable(), checkpoint).await; +/// The first video rendition by name, 1080p, is the reference. +async fn segments(count: u64) -> Recording { + let mut recording = Recording::new().await; for segment in 0..count { add_segment(&mut recording, segment, 0); } @@ -456,7 +404,7 @@ fn add_segment(recording: &mut Recording, segment: u64, pop: u64) { #[tokio::test(start_paused = true)] async fn playlists_read_only_timelines_and_segments_their_own_objects() { - let recording = segments(3, None).await; + let recording = segments(3).await; recording.stored().await; let replay = Replay::open(&recording.store, 64 * 1024 * 1024).await; @@ -466,7 +414,7 @@ async fn playlists_read_only_timelines_and_segments_their_own_objects() { // Render and reload every playlist with media GETs refused: shared numbering, and not one // media GET. The reference lists every segment; the others hold back the newest until their // own timeline passes its end, or ends. - recording.store.inner().reject_media(true); + recording.counting.reject_media(true); for _ in 0..2 { for (kind, name, listed) in [ (Kind::Video, "1080p", 3), @@ -489,7 +437,7 @@ async fn playlists_read_only_timelines_and_segments_their_own_objects() { ); } assert!(gets.iter().any(|path| path.contains("timeline")), "{gets:?}"); - recording.store.inner().reject_media(false); + recording.counting.reject_media(false); // Switching renditions downloads only the selected rendition's objects. for (kind, name, segment) in [ @@ -526,7 +474,7 @@ async fn playlists_read_only_timelines_and_segments_their_own_objects() { #[tokio::test(start_paused = true)] async fn a_bounded_cache_rereads_evicted_objects() { - let recording = segments(3, None).await; + let recording = segments(3).await; recording.stored().await; // Too small for any object, so nothing stays cached. let replay = Replay::open(&recording.store, 1).await; @@ -547,7 +495,7 @@ async fn a_bounded_cache_rereads_evicted_objects() { #[tokio::test(start_paused = true)] async fn missing_rendition_segments_are_gaps_and_time_jumps_are_discontinuities() { - let mut recording = Recording::new(durable(), None).await; + let mut recording = Recording::new().await; recording.add("1080p", 0, 2000, &[(0, &[0])], 0); recording.add("360p", 0, 8000, &[(0, &[0])], 0); recording.add("audio", 0, 2000, &[(0, &[0])], 0); @@ -557,6 +505,7 @@ async fn missing_rendition_segments_are_gaps_and_time_jumps_are_discontinuities( recording.add("1080p", 10_000, 2000, &[(2, &[10_000_000])], 0); recording.add("360p", 10_000, 2000, &[(1, &[10_000_000])], 0); // The completed archive resolves each rendition's newest segment. + let counting = recording.counting.clone(); let store = recording.finish().await; let replay = Replay::open(&store, 64 * 1024 * 1024).await; @@ -577,10 +526,10 @@ async fn missing_rendition_segments_are_gaps_and_time_jumps_are_discontinuities( ); // A gap is never fetched. - store.inner().take(); + counting.take(); let rendition = replay.rendition(Kind::Video, "360p"); assert!(rendition.listed_segment(1).await.unwrap().is_none()); - assert!(!store.inner().take().iter().any(|path| is_media(path))); + assert!(!counting.take().iter().any(|path| is_media(path))); let after = rendition.listed_segment(2).await.unwrap().unwrap(); assert_eq!(&after[4..8], b"moof"); } @@ -589,7 +538,7 @@ async fn missing_rendition_segments_are_gaps_and_time_jumps_are_discontinuities( /// complete. #[tokio::test(start_paused = true)] async fn a_growing_recording_ends_with_its_markers() { - let mut recording = segments(3, None).await; + let mut recording = segments(3).await; recording.stored().await; let replay = Replay::open(&recording.store, 64 * 1024 * 1024).await; replay.playlist(Kind::Video, "1080p").await; @@ -620,7 +569,7 @@ async fn a_growing_recording_ends_with_its_markers() { /// however much the store retains. #[tokio::test(start_paused = true)] async fn a_durable_timeline_lists_the_window() { - let recording = segments(12, None).await; + let recording = segments(12).await; recording.stored().await; let replay = Replay::open(&recording.store, 64 * 1024 * 1024).await; let playlist = replay From a9f0e23c4afc22ae78382844a64611229a918ad3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 22:37:33 -0700 Subject: [PATCH 44/48] fix(archive): refuse a group whose GET fails for now instead of dropping it A store outage is not the archive losing the object, so a later reader asks again once the store recovers. Only an object the store no longer has is dropped. Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/consumer/tests.rs | 17 +++++++++++++++++ rs/moq-archive/src/consumer/track.rs | 21 ++++++++++++--------- rs/moq-archive/src/mock.rs | 19 +++++++++++++++++++ 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index 32835f45133..323fb779785 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -387,6 +387,23 @@ async fn a_pruned_start_reads_as_dropped() { not_found(fetch(&broadcast, "video", 7).await); } +/// A GET that fails while the store still lists the object refuses its group only for now, so +/// a later read asks again once the store recovers. +#[tokio::test(start_paused = true)] +async fn a_failed_get_refuses_its_group_for_now() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.object("video", 0, vec![frames(true, 0, 0..2)]).await; + archive.object("video", 0, vec![frames(true, 1, 0..1)]).await; + + let (broadcast, _run) = serve(&archive.store).await; + archive.store.inner().fail_gets("rec/video/0000000000000000000-"); + let err = fetch(&broadcast, "video", 0).await.unwrap_err(); + assert!(!matches!(err, moq_net::Error::NotFound), "{err}"); + archive.store.inner().heal_gets(); + assert_eq!(fetch(&broadcast, "video", 0).await.unwrap(), expected(true, 0, 0..2)); +} + /// A growing archive is followed through each track's own cursor, a key sorting before /// another track's cursor included, and a track added mid-recording joins. #[tokio::test(start_paused = true)] diff --git a/rs/moq-archive/src/consumer/track.rs b/rs/moq-archive/src/consumer/track.rs index ac23470bf71..6a4f514c999 100644 --- a/rs/moq-archive/src/consumer/track.rs +++ b/rs/moq-archive/src/consumer/track.rs @@ -149,8 +149,8 @@ pub(super) async fn serve(shared: Arc>, index: Arc(shared: &Shared, index: &Mutex, fill: &Fill, sequence: u64) { let frame = fill.frame_start(sequence); let (holding, after, timed, timescale) = { @@ -171,16 +171,19 @@ async fn serve_group(shared: &Shared, index: &Mutex, f }; match loaded { Ok(object) => objects.push(object), - Err(err) => { - if let Error::NotFound(_) = err { - // Pruned since it was listed. - index.lock().unwrap().forget(*start); - } else { - tracing::warn!(%path, sequence, %err, "archived group is unavailable"); - } + Err(Error::NotFound(_)) => { + // Pruned since it was listed. + index.lock().unwrap().forget(*start); fill.drop_groups(sequence..sequence + 1); return; } + Err(err) => { + // The store still lists it, so a later reader asks again. + tracing::warn!(%path, sequence, %err, "archived group is unavailable"); + let err = moq_net::Error::Stream(moq_net::StreamError::Internal); + fill.refuse_groups(sequence..sequence + 1, err); + return; + } } } diff --git a/rs/moq-archive/src/mock.rs b/rs/moq-archive/src/mock.rs index b48cdd8951d..b0e82f80654 100644 --- a/rs/moq-archive/src/mock.rs +++ b/rs/moq-archive/src/mock.rs @@ -31,6 +31,8 @@ struct State { fail_puts: Vec, /// GETs whose path contains any of these return Not Found. hide_gets: Vec, + /// GETs whose path contains any of these fail, as a store outage would. + fail_gets: Vec, /// GETs whose path contains the pattern wait for a permit before reading. stall_gets: Vec<(String, Arc)>, /// Deletes whose path contains any of these fail. @@ -99,6 +101,17 @@ impl Mock { self } + pub fn fail_gets(&self, pattern: &str) -> &Self { + self.state().fail_gets.push(pattern.to_string()); + self + } + + /// End every [`fail_gets`](Self::fail_gets) outage. + pub fn heal_gets(&self) -> &Self { + self.state().fail_gets.clear(); + self + } + /// Hold every GET whose path contains `pattern` until the returned semaphore gets a permit. pub fn stall_gets(&self, pattern: &str) -> Arc { let gate = Arc::new(tokio::sync::Semaphore::new(0)); @@ -237,6 +250,12 @@ impl ObjectStore for Mock { source: "hidden".into(), }); } + if self.state().fail_gets.iter().any(|p| location.as_ref().contains(p)) { + return Err(object_store::Error::Generic { + store: "mock", + source: "outage".into(), + }); + } let stall = self .state() .stall_gets From 003a088753a0c7b0578f75b1d419a09b49396c50 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 22:46:54 -0700 Subject: [PATCH 45/48] fix(archive): abort the served tracks when serving the archive fails A refresh or apply error used to drop every served track, the open newest group included, so readers saw a dropped producer. They are now aborted with an internal stream error, also when `open` fails partway. Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/consumer/mod.rs | 22 +++++++++++++++++++++- rs/moq-archive/src/consumer/tests.rs | 24 ++++++++++++++++++++++++ rs/moq-archive/src/consumer/track.rs | 9 +++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/rs/moq-archive/src/consumer/mod.rs b/rs/moq-archive/src/consumer/mod.rs index fcaf1d141a8..c6a4d824617 100644 --- a/rs/moq-archive/src/consumer/mod.rs +++ b/rs/moq-archive/src/consumer/mod.rs @@ -147,14 +147,34 @@ impl Consumer { if refreshed.added.is_empty() { return Err(Error::NotFound(consumer.shared.store.prefix().to_string())); } - consumer.apply(refreshed)?; + if let Err(err) = consumer.apply(refreshed) { + consumer.abort(); + return Err(err); + } Ok(consumer) } /// Serve the archive until the broadcast closes, following it while it grows. /// /// Fails when the store does, or when it holds a key or object the layout does not name. + /// Every served track is then aborted, so its readers see the failure. pub async fn run(mut self) -> Result<()> { + let result = self.serve().await; + if result.is_err() { + self.abort(); + } + result + } + + /// Abort every served track, its open group included, after serving failed. + fn abort(&mut self) { + let err = moq_net::Error::Stream(moq_net::StreamError::Internal); + for (_, track) in self.tracks.drain() { + track.live.abort(err.clone()); + } + } + + async fn serve(&mut self) -> Result<()> { let mut fills = kio::Tasks::new(); let mut refreshing: Option>> = None; diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index 323fb779785..cf342e69b41 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -462,6 +462,30 @@ async fn an_archive_without_markers_stays_open() { assert!(pending.is_err(), "the newest group stays open"); } +/// A store failure ends the replay and aborts every served track with it, the open newest group +/// included, so readers see the failure rather than a dropped producer. +#[tokio::test(start_paused = true)] +async fn a_store_failure_aborts_the_served_tracks() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.object("video", 0, vec![frames(true, 0, 0..1)]).await; + + let (broadcast, run) = serve(&archive.store).await; + let mut reader = subscribe(&broadcast, "video").await; + let mut group = reader.next_group().await.unwrap().unwrap(); + assert!(group.read_frame().await.unwrap().is_some()); + + archive.store.inner().fail_lists(); + assert!(run.await.unwrap().is_err()); + let failed = |err: &moq_net::Error| matches!(err, moq_net::Error::Stream(moq_net::StreamError::Internal)); + let err = group.read_frame().await.unwrap_err(); + assert!(failed(&err), "{err}"); + let Err(err) = reader.next_group().await else { + panic!("the track is aborted"); + }; + assert!(failed(&err), "{err}"); +} + /// A completed archive ends each track at its recorded end, mid-group included, and an /// errored track replays its stored groups. #[tokio::test(start_paused = true)] diff --git a/rs/moq-archive/src/consumer/track.rs b/rs/moq-archive/src/consumer/track.rs index 6a4f514c999..cab2bd567ee 100644 --- a/rs/moq-archive/src/consumer/track.rs +++ b/rs/moq-archive/src/consumer/track.rs @@ -117,6 +117,15 @@ impl Live { Ok(()) } + /// End the feed with `err` when serving the archive failed, the open group included, so + /// readers see why rather than a dropped producer. + pub fn abort(self, err: moq_net::Error) { + if let Some(open) = self.open { + let _ = open.group.abort(err.clone()); + } + let _ = self.producer.abort(err); + } + /// End the feed as the archive recorded. pub fn end(&mut self, end: End, track: &str) -> Result<()> { self.close()?; From 15ac5cb825d65036cb704dd3af2ddf4427b59432 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 22:56:26 -0700 Subject: [PATCH 46/48] fix(archive): cancel the tracks a consumer still serves live when it stops Closing the broadcast, or dropping the consumer, used to drop every track still live, so readers saw a dropped producer. Dropping a consumer whose archive has not ended now cancels them, the open newest group included. Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/consumer/mod.rs | 33 ++++++++++++++++++---------- rs/moq-archive/src/consumer/tests.rs | 19 ++++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/rs/moq-archive/src/consumer/mod.rs b/rs/moq-archive/src/consumer/mod.rs index c6a4d824617..832472b86e5 100644 --- a/rs/moq-archive/src/consumer/mod.rs +++ b/rs/moq-archive/src/consumer/mod.rs @@ -148,7 +148,7 @@ impl Consumer { return Err(Error::NotFound(consumer.shared.store.prefix().to_string())); } if let Err(err) = consumer.apply(refreshed) { - consumer.abort(); + consumer.abort(moq_net::Error::Stream(moq_net::StreamError::Internal)); return Err(err); } Ok(consumer) @@ -157,23 +157,16 @@ impl Consumer { /// Serve the archive until the broadcast closes, following it while it grows. /// /// Fails when the store does, or when it holds a key or object the layout does not name. - /// Every served track is then aborted, so its readers see the failure. + /// Every track still live is then aborted with that failure; one left live when the + /// broadcast closes, or when the consumer is dropped, is cancelled. pub async fn run(mut self) -> Result<()> { let result = self.serve().await; if result.is_err() { - self.abort(); + self.abort(moq_net::Error::Stream(moq_net::StreamError::Internal)); } result } - /// Abort every served track, its open group included, after serving failed. - fn abort(&mut self) { - let err = moq_net::Error::Stream(moq_net::StreamError::Internal); - for (_, track) in self.tracks.drain() { - track.live.abort(err.clone()); - } - } - async fn serve(&mut self) -> Result<()> { let mut fills = kio::Tasks::new(); let mut refreshing: Option>> = None; @@ -271,6 +264,24 @@ impl Consumer { } } +impl Consumer { + /// Abort every served track with `err`, its open group included. + fn abort(&mut self, err: moq_net::Error) { + for (_, track) in self.tracks.drain() { + track.live.abort(err.clone()); + } + } +} + +impl Drop for Consumer { + fn drop(&mut self) { + // An archive that ended already finished every track as recorded. + if !self.ended { + self.abort(moq_net::Error::Cancel); + } + } +} + /// List the prefix for added tracks and `.end`, then each track after its newest key, and GET /// what each track's live feed takes next. async fn refresh(shared: Arc>, mut tracks: Vec) -> Result { diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index cf342e69b41..73c466f3c16 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -462,6 +462,25 @@ async fn an_archive_without_markers_stays_open() { assert!(pending.is_err(), "the newest group stays open"); } +/// Closing the broadcast ends a followed archive and cancels the tracks it still served live, +/// the open newest group included. +#[tokio::test(start_paused = true)] +async fn closing_the_broadcast_cancels_the_live_tracks() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.object("video", 0, vec![frames(true, 0, 0..1)]).await; + + let (broadcast, run) = serve(&archive.store).await; + let mut reader = subscribe(&broadcast, "video").await; + let mut group = reader.next_group().await.unwrap().unwrap(); + assert!(group.read_frame().await.unwrap().is_some()); + + broadcast.close(); + run.await.unwrap().unwrap(); + let err = group.read_frame().await.unwrap_err(); + assert!(matches!(err, moq_net::Error::Cancel), "{err}"); +} + /// A store failure ends the replay and aborts every served track with it, the open newest group /// included, so readers see the failure rather than a dropped producer. #[tokio::test(start_paused = true)] From 5eb0ba9504896a594e84d232bab0146816b7424f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 23:05:09 -0700 Subject: [PATCH 47/48] fix(archive): keep the open group in place while a continuation is written A continuation that skipped frames, or a write that failed, took the open group out before failing, so it was dropped rather than aborted with the track. It now stays in `open` until written. Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/consumer/tests.rs | 23 +++++++++++++++++++++++ rs/moq-archive/src/consumer/track.rs | 28 +++++++++++++++------------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index 73c466f3c16..5f213682ba0 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -481,6 +481,29 @@ async fn closing_the_broadcast_cancels_the_live_tracks() { assert!(matches!(err, moq_net::Error::Cancel), "{err}"); } +/// A continuation that skips frames of the open group fails the replay, and the open group is +/// aborted with the track rather than dropped. +#[tokio::test(start_paused = true)] +async fn a_gapped_continuation_aborts_the_open_group() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive.object("video", 0, vec![frames(true, 0, 0..1)]).await; + + let (broadcast, run) = serve(&archive.store).await; + let mut reader = subscribe(&broadcast, "video").await; + let mut group = reader.next_group().await.unwrap().unwrap(); + assert!(group.read_frame().await.unwrap().is_some()); + + // Frame 1 of group 0 is missing. + archive.object("video", 2, vec![frames(true, 0, 2..3)]).await; + assert!(matches!(run.await.unwrap(), Err(Error::Sequence))); + let err = group.read_frame().await.unwrap_err(); + assert!( + matches!(err, moq_net::Error::Stream(moq_net::StreamError::Internal)), + "{err}" + ); +} + /// A store failure ends the replay and aborts every served track with it, the open newest group /// included, so readers see the failure rather than a dropped producer. #[tokio::test(start_paused = true)] diff --git a/rs/moq-archive/src/consumer/track.rs b/rs/moq-archive/src/consumer/track.rs index cab2bd567ee..46336d1fa90 100644 --- a/rs/moq-archive/src/consumer/track.rs +++ b/rs/moq-archive/src/consumer/track.rs @@ -76,27 +76,30 @@ impl Live { .insert_datagram(*sequence, timestamp, frame.payload.clone())?; } Entry::Group { sequence, frames } => { - let mut open = match self.open.take() { - Some(open) if open.sequence == *sequence => { + if self.open.as_ref().is_some_and(|open| open.sequence != *sequence) { + self.close()?; + } + // The group stays in `open` while it is written, so a failure aborts it with + // the track rather than dropping it. + let open = match &mut self.open { + Some(open) => { if open.next != first { return Err(Error::Sequence); } open } - other => { - if let Some(mut other) = other { - other.group.finish()?; - } - let mut group = self.producer.create_group(group::Info { sequence: *sequence })?; - if first > 0 { - // Its head was pruned. - group.start_at(first)?; - } - Open { + None => { + let group = self.producer.create_group(group::Info { sequence: *sequence })?; + let open = self.open.insert(Open { group, sequence: *sequence, next: first, + }); + if first > 0 { + // Its head was pruned. + open.group.start_at(first)?; } + open } }; for frame in frames { @@ -104,7 +107,6 @@ impl Live { open.group.write_frame(timestamp, frame.payload.clone())?; open.next += 1; } - self.open = Some(open); } } Ok(()) From b873079a9376779626414a83747eb8a113a17b82 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 23:16:58 -0700 Subject: [PATCH 48/48] fix(archive): settle a recorded end past the newest stored group Empty groups store no object, so a track that ended after one never let its replay's live edge reach the recorded end, and readers waited forever. The sequence just below the end replays as an empty group and any before it as dropped. Co-Authored-By: Claude Opus 5.5 --- rs/moq-archive/src/consumer/tests.rs | 11 +++++++++ rs/moq-archive/src/consumer/track.rs | 34 ++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index 5f213682ba0..63707ff281d 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -536,6 +536,7 @@ async fn a_completed_archive_ends_as_recorded() { archive.track("video", 0, true).await; archive.track("audio", 0, true).await; archive.track("errored", 0, true).await; + archive.track("tail", 0, true).await; archive .object("video", 0, vec![frames(true, 0, 0..2), frames(true, 1, 0..2)]) .await; @@ -548,6 +549,9 @@ async fn a_completed_archive_ends_as_recorded() { archive.end("video", End::Finish(At { group: 2, frame: 0 })).await; archive.end("audio", End::Finish(At { group: 1, frame: 2 })).await; archive.end("errored", End::Error(64 + 7)).await; + // Empty groups store no object, only the end past them. + archive.object("tail", 0, vec![frames(true, 0, 0..1)]).await; + archive.end("tail", End::Finish(At { group: 4, frame: 0 })).await; archive.complete().await; let (broadcast, _run) = serve(&archive.store).await; @@ -566,6 +570,13 @@ async fn a_completed_archive_ends_as_recorded() { assert_eq!(next(&mut reader).await.unwrap().0, 0); assert_eq!(next(&mut reader).await.unwrap().0, 1); assert!(next(&mut reader).await.is_none()); + + // The sequence below the end replays as an empty group, and those before it as dropped. + let mut reader = subscribe(&broadcast, "tail").await; + assert_eq!(next(&mut reader).await.unwrap(), (0, expected(true, 0, 0..1))); + assert_eq!(next(&mut reader).await.unwrap(), (3, Vec::new())); + assert!(next(&mut reader).await.is_none()); + assert_eq!(reader.finished().await.unwrap(), Position::group(4)); } /// Markers written while the consumer follows end the tracks then. diff --git a/rs/moq-archive/src/consumer/track.rs b/rs/moq-archive/src/consumer/track.rs index 46336d1fa90..914afc5b5dd 100644 --- a/rs/moq-archive/src/consumer/track.rs +++ b/rs/moq-archive/src/consumer/track.rs @@ -18,8 +18,9 @@ pub(super) struct Live { timescale: Option, /// The newest group, kept open since a later object may continue it. open: Option, - /// Whether the feed declared where it starts; fills serve everything before. - started: bool, + /// One past the newest sequence fed, once the feed declared where it starts; fills serve + /// everything before that start. + next: Option, } struct Open { @@ -36,12 +37,12 @@ impl Live { producer, timescale, open: None, - started: false, + next: None, } } pub fn is_started(&self) -> bool { - self.started + self.next.is_some() } /// Feed `objects`' entries from sequence `from` on, in order. @@ -51,16 +52,16 @@ impl Live { if entry.sequence() < from { continue; } - if !self.started { + if self.next.is_none() { // Readers ask fills for everything older. self.producer.start_at(entry.sequence())?; - self.started = true; } let first = match index { 0 => object.frame_start, _ => 0, }; self.entry(entry, first)?; + self.next = Some(entry.sequence() + 1); } } Ok(()) @@ -132,10 +133,23 @@ impl Live { pub fn end(&mut self, end: End, track: &str) -> Result<()> { self.close()?; match end { - End::Finish(at) => self.producer.finish_at(Position { - group: at.group, - frame: at.frame, - })?, + End::Finish(at) => { + self.producer.finish_at(Position { + group: at.group, + frame: at.frame, + })?; + // An empty group stores no object, yet the end only settles once the live edge + // reaches it. The sequence just below the end replays as the empty group a + // source most often ended with, and any before it as dropped. + let fin = at.group + u64::from(at.frame != 0); + let from = self.next.unwrap_or(0); + if from < fin { + self.producer.drop_groups(from..fin - 1, moq_net::StreamError::NotFound); + self.producer + .create_group(group::Info { sequence: fin - 1 })? + .finish()?; + } + } End::Error(code) => { // An abort would refuse every later reader the stored history, so the track // ends cleanly after its newest stored sequence instead.