diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index aa0a5afeef6..b95a4e97690 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand::is_used`, `used`, and `unused` are gone. diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index ed1ab20569c..3e45fc90d91 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand.isUsed()`, `used()`, and `unused()` are gone. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 3912804c3b4..a451a7cec54 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -233,10 +233,12 @@ On moq-lite 05 and later, a publisher explicitly reports groups that will not ar This lets a finished subscription settle without waiting for missing groups. Datagram delivery stays best effort. -Ranges are a request, not a filter. A subscriber reads from where its lowest -range starts, and may also get groups another subscriber of the same track -asked for. A relay merges its subscribers' ranges into one upstream request, -which today's wire versions carry as the single span covering them. +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 +today's wire versions carry as the single span covering them. Order only decides what goes first, never what is skipped, so catching up oldest first needs a max delay that covers the backlog. When a relay serves subscribers of diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 47453fde5bd..20aca4079cb 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -1194,6 +1194,7 @@ MUST be 0 when `Group End` is 0, since an unbounded subscription has no end grou A subscriber can modify a subscription with a SUBSCRIBE_UPDATE message. A subscriber MAY send multiple SUBSCRIBE_UPDATE messages to update the subscription. The start and end group can be changed in either direction (growing or shrinking). +Lowering `Group Start` covers the groups below the old start that the publisher still holds, not only those that arrive later; a group already sent is not sent again. ~~~ SUBSCRIBE_UPDATE Message { @@ -1618,6 +1619,7 @@ The `Message Length` describes the payload size on the wire. - SUBSCRIBE_OK `Group` names where delivery starts when the subscription resolves, which need not be the first group sent. It is not a new floor. A subscriber does not wait for a group between the requested floor and it, but a publisher still delivers one that arrives later within Subscriber Max Age. - Corrected the SUBSCRIBE note that offset `Group Start` by 1. Only `Group End` and `Frame End` are offset so 0 can mean absent. `Group Start` is an absolute sequence, and 0 is group 0. - A subscription's range bounds datagrams like groups, and FETCH never returns a datagram. +- A SUBSCRIBE_UPDATE that lowers `Group Start` covers the groups below the old start that the publisher still holds, not only those that arrive later. A group already sent is not sent again. - A publisher SHOULD order a datagram among the subscription's streams as if it were its Group's stream, ahead of that stream when both exist. It SHOULD drop a datagram rather than block when the transport has no room for it, where it previously sent one only if the congestion controller could transmit it immediately. - Assigned 0x3A NOT_FETCHABLE in the stream error table: a FETCH for a group delivered only as a datagram. - The subscriber FINs its Subscribe Stream after settling its tail; graceful session close waits for that FIN or reset. diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index fb4aaa95358..5dbc9ca53ea 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `Ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand.IsUsed`, `Used`, and `Unused` are gone. diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index ca9296bd2cd..880c0c67e5b 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand.isUsed()`, `used()`, and `unused()` are gone. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index 733e35e2118..3a49615cdf0 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand.is_used()`, `used()`, and `unused()` are gone. diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index 7a74ce43995..b67786518ed 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -72,7 +72,9 @@ relay bounds a sparse FETCH only if the model can request ranges: has no time filter. It is part of lite-07 and must land before [lite-07 finalizes](/quest/m1/lite07-finalize.md), which this line gates. This amends the 09-29 premise that ranges are group-only because a group's timestamp isn't always - known: timed tracks know it, and untimed ones refuse. + known: timed tracks know it, and untimed ones refuse. Split out of #5929 + (2026-10-19) into [time anchors](/quest/m1/subscribe-ranges/time-anchors.md), + which lands in its own PR. - Ranges are frame-precise (`Position`), not whole groups. The IETF joining FETCH for a mid-group SUBSCRIBE's uncached prefix stays, because today's bridge relies on it (maintainer, 09-29). @@ -85,10 +87,12 @@ every sequence delivered or dropped. ## Required -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `Dynamic` fills misses by range, a subscriber reads only its own ranges, and a range end may be a time +- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `Dynamic` fills misses by range, and a subscriber reads only its own ranges - [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 - [JS ranges](/quest/m1/subscribe-ranges/js.md) - `@moq/net` model and lite-07 parity +- [Frame bounds](/quest/m1/subscribe-ranges/frame-bounds.md) - a local subscriber reads only the frames its ranges cover +- [Time anchors](/quest/m1/subscribe-ranges/time-anchors.md) - a range start or end may name a presentation time ## Related diff --git a/quest/m1/subscribe-ranges/dynamic.md b/quest/m1/subscribe-ranges/dynamic.md index ba6dfd30fda..5e7b850d66e 100644 --- a/quest/m1/subscribe-ranges/dynamic.md +++ b/quest/m1/subscribe-ranges/dynamic.md @@ -5,25 +5,16 @@ A publisher's `track::Dynamic` receives range requests for misses instead of one request per group, and a track serves a subscription's ranges from one cursor, in its order, without duplicating a group, naming every sequence in -range it won't deliver. A range end may be a `Time(pts)`, resolved by -whoever holds the track. `requested_group` and `fetch_group` are replaced, not +range it won't deliver. `requested_group` and `fetch_group` are replaced, not kept beside it. ## Plan -`track::Subscription` already carries aggregated `ranges`, but only as a wire -preference: locally a subscriber's ranges set where its reads start, and it -still reads every later group the aggregate brings in. Serving a range locally -means filtering a subscriber's reads to its own ranges, and a miss below the -cache becomes a range request on `Dynamic` rather than a group request. +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. Time anchors land separately; see +[time anchors](/quest/m1/subscribe-ranges/time-anchors.md). -- Add a `Time(pts)` anchor beside `Absolute` and `Relative` (see the - [line](/quest/m1/subscribe-ranges/README.md)), carried through moq-ffi and - every binding like the other anchors. A track resolves it from its cached - groups' timestamps and passes an unresolved one to its `track::Dynamic` - handler; an untimed track refuses it. Test a time start and end resolved - against the cache, an unresolved one passed through to a handler, and an - untimed track refusing one. - Replace `requested_group` and `fetch_group` rather than keep both. Update every caller (relay, moq-archive, hls, the ladder) in the same PR, including the archive consumer's time resolution from its key listing if it @@ -34,7 +25,9 @@ cache becomes a range request on `Dynamic` rather than a group request. `moq_relay::fetch_group` (added in the 2026-10-05 audit). - Every sequence in range either arrives or is named as dropped, so a reader never waits on a miss. Share the accounting with - SUBSCRIBE_DROP rather than adding a second one. + 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 @@ -43,14 +36,5 @@ cache becomes a range request on `Dynamic` rather than a group request. budget or lets one expired caller cancel another still-valid reader. Reuse FETCH's optional per-reader budget and publisher clamp rather than restoring a historical exemption. -- Move every relative-start resolution into the model, which counts back - from produced groups only. Today two consumers re-resolve relative starts - on their own. moq-c's `apply_raw_subscription` (`rs/moq-c/src/consume.rs`) - counts back from `Ordered::latest()`, the shared group and datagram - sequence, so a datagram past the newest group skews the start. It also - re-resolves on every update, because the model doesn't. moq-mux's - `container::Consumer::new` treats a relative start as an unfloored join - and adopts the first arrival, which under newest-first delivery can skip - the older groups the range asked for. -- Benchmark serving and filling over range count and span as separate axes, - beside `track_subscription_ranges`, which covers aggregation. +- Benchmark filling over range count and span as separate axes, beside + `track_range_serving`, which covers serving. diff --git a/quest/m1/subscribe-ranges/frame-bounds.md b/quest/m1/subscribe-ranges/frame-bounds.md new file mode 100644 index 00000000000..23c940af714 --- /dev/null +++ b/quest/m1/subscribe-ranges/frame-bounds.md @@ -0,0 +1,37 @@ +# [M] A local subscriber reads only its frame bounds + +## Goal + +A `track::Subscriber` whose ranges start or end partway through a group reads only the +frames they cover: a start at group 4 frame 5 hands out group 4 from frame 5, and an +end mid-group stops there, whatever another subscriber widened the aggregate to. Today +the model filters whole groups and hands out every cached frame, so local readers +(moq-ffi, moq-mux, in-process apps) see frames they never asked for. + +## Plan + +Found by Codex on #5929 and reproduced on `main` (824179a3a) as well as on that PR: a +subscription starting at frame 5 of group 0 is handed the group from frame 0. #5929's +`Window` collapses each range to group sequences, but the leak predates it: `main`'s +cursor never positioned a delivered group either. Only the datagram path checks the +start frame (`mid_group` in `Cursor::poll_recv_datagram`). + +The wire is not affected: the lite publisher positions each served group per +subscription (`position_group` in `rs/moq-net/src/lite/publisher.rs`, and the same +shape in `js/net/src/lite/publisher.ts`). + +- Keep each range's absolute frame bounds in the `Window` beside its group runs. +- Position every group the cursor hands out (`group::Consumer::start_at`/`end_at`), on + the arrival, ordered, and re-offer paths, using the bounds of the range that covers it. + A group whose head is gone and that was asked for from frame 0 stays a miss, as + `position_group` does today. +- Then let the lite publisher rely on the model's positioning instead of its own, if + that is simpler. +- Check `js/net`'s `track` subscriber for the same shape and mirror the fix. + +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/time-anchors.md b/quest/m1/subscribe-ranges/time-anchors.md new file mode 100644 index 00000000000..b66dd31b474 --- /dev/null +++ b/quest/m1/subscribe-ranges/time-anchors.md @@ -0,0 +1,56 @@ +# [L] Time anchors + +## Goal + +A range start or end may name a presentation time: `Time(pts)` in the track's +timescale. A start resolves to the group containing pts, an end to just after that +group, and an untimed track refuses either. It lands in its own PR, before +[lite-07 finalizes](/quest/m1/lite07-finalize.md). + +## Plan + +Split out of #5929 (maintainer, 2026-10-19), which ships absolute and relative +anchors only. Start from the `quest/m1/subscribe-ranges/time-end` branch, which +holds that PR's time anchors through every binding: `Anchor::Time`, the +`Holdings` resolution in `Window`, the end settlement (`Holdings::after`, +`TrackState::accounted`), and the failover handling. Merge main into it and drop +what #5929 changed since. + +Decide first whether time starts ship alone. A start resolves once from the +cache, but keeping it without time ends needs a start-only anchor type in every +binding, which is why #5929 dropped both. + +Open findings from #5929's review, each needing a regression test: + +- **Per-read rescan behind a peer-controlled gap** (Codex 4320985614). Settling an + unresolved end walks back from the live edge on every unsettled read. A peer that + leaves an early sequence missing and keeps sending later groups makes each read + revisit a growing prefix until the gap's grace runs out. Keep an incremental + settle boundary, so each read scans only what arrived since. +- **Failover replacing an unsettled end** (Codex 4320985627). A copy capped at a + route switch reports an unsettled time end as unresolved, so the cap replaces + the `Time` anchor for good, and a group starting before the time that arrives + later on the old copy is delivered. Keep the time filter on the reader, separate + from the copy cap. +- **One speculative group past an end.** An unresolved end covers every sequence + through `u64::MAX`, so each group handed out before its first frame can leak past + it at the live edge. Cap it at the newest held group known to start at or before + pts, plus the first unstarted group above it, and recompute as groups start. + Raising the cap must wake readers: register on the speculative group's start + (`group::Producer::poll_started`) from both `Cursor::poll_recv_group` and + `poll_next_group`. A held group that never shows a start (a copy joined + mid-group) must not pin the cap. Datagrams are not in `lookup`, so on a + datagram-only timed track an end never resolves: resolve datagram starts too, + or leave datagram reads uncapped. Reproduced on #5929: range + `time(3000)..time(3500)` with three groups opened before any frame delivers + all three. + +Also pass a time anchor the cache can't resolve to the `track::Dynamic` handler +in its range request, once [dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) +land, and update `quest/m1/binding-timestamps.md` for the anchor's +`timestamp_us` fields. + +## Related + +- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - range requests a `Dynamic` handler resolves +- [Archive line](/quest/m1/archive/README.md) - DVR seek, which needs time anchors diff --git a/rs/moq-c/MIGRATE.md b/rs/moq-c/MIGRATE.md index 3f6ece96e60..6d7a0db5163 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads (#5929) + +A raw track consumer now reads only the groups its `ranges` cover, rather than every group from the lowest start to the last absolute end. + ### Demand reports the wanted group (#5930) `moq_track_info` gains `refresh` and `refresh_present`, which changes its ABI; a zeroed field leaves refresh off. diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 95fda0aaa4c..4c2871f0cef 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -2,7 +2,6 @@ use std::{ collections::BTreeMap, ffi::c_char, future::Future, - ops::Bound, pin::Pin, task::{Poll, ready}, }; @@ -137,90 +136,6 @@ struct RawTaskEntry { update: mpsc::UnboundedSender>, } -/// A raw track's reader, with the track handle its subscription updates resolve against. -struct RawReader { - // A relative range counts from this track's largest: the reader's own reports only - // within the bounds the update is replacing. Kept unpolled, so each clone reports the - // current largest rather than only a newer one. - edge: moq_net::track::Consumer, - track: moq_net::track::Ordered, - // An update with a relative range, whose bounds wait for the edge to be live. - deferred: Option, -} - -impl RawReader { - fn new(edge: moq_net::track::Consumer, track: moq_net::track::Ordered) -> Self { - Self { - edge, - track, - deferred: None, - } - } - - /// Apply a subscription update: its wire preferences at once, and its local bounds once - /// any relative range has a live edge to count from. - fn apply(&mut self, subscription: Option, waiter: &kio::Waiter) { - let subscription = subscription.unwrap_or_default(); - // A closed track makes the update meaningless; the reader already sees the close. - let _ = self.track.update(subscription.clone()); - let relative = subscription - .ranges - .iter() - .any(|range| matches!(range.start, moq_net::track::Anchor::Relative(_))); - match relative { - true => { - self.deferred = Some(subscription); - self.poll_deferred(waiter); - } - false => { - self.deferred = None; - self.set_bounds(&subscription, None); - } - } - } - - /// Apply a deferred update's bounds once the edge is live, or the track ended. - fn poll_deferred(&mut self, waiter: &kio::Waiter) { - let Some(subscription) = &self.deferred else { - return; - }; - if let Poll::Ready(largest) = self.edge.clone().poll_largest(waiter) { - let subscription = subscription.clone(); - self.deferred = None; - self.set_bounds(&subscription, largest.map(|position| position.group)); - } - } - - fn set_bounds(&mut self, subscription: &moq_net::track::Subscription, latest: Option) { - // Reads start where the lowest range does. Without a floor of its own (the oldest - // range, the default) a raw reader joins at the latest group. - let start = subscription - .ranges - .iter() - .map(|range| match range.start { - moq_net::track::Anchor::Absolute(start) => Some(start.group), - moq_net::track::Anchor::Relative(u64::MAX) => latest, - moq_net::track::Anchor::Relative(back) => latest.map(|latest| latest.saturating_sub(back)), - }) - .min() - .flatten(); - // They stop past the last absolute end; an open or relative one follows the live edge. - let end = subscription - .ranges - .iter() - .map(|range| match range.end { - Some(moq_net::track::Anchor::Absolute(end)) => Some(end), - _ => None, - }) - .collect::>>() - .map(|ends| ends.into_iter().max().unwrap_or_default()); - self.track.set_groups(( - start.map_or(Bound::Unbounded, Bound::Included), - end.map_or(Bound::Unbounded, moq_net::track::Position::group_end), - )); - } -} - /// Outcome of polling a raw track source alongside its control channels. enum RawStep { /// A delivered value: the next group, or the next frame within a group. @@ -668,12 +583,9 @@ impl Consume { // `subscribe` blocks on SUBSCRIBE_OK, so run it inside the task. tokio::spawn(async move { let res = crate::ffi::run(driver.watch(async move { - let edge = broadcast.track(&name)?; - let track = edge.subscribe(subscription.clone()).await?.ordered(); - let mut reader = RawReader::new(edge, track); - // The task's own polls re-poll a deferred update with their waiter. - reader.apply(subscription, &kio::Waiter::noop()); - Self::run_raw(on_frame, reader, close, updates).await + let mut track = broadcast.track(&name)?.subscribe(subscription.clone()).await?.ordered(); + Self::apply_raw_subscription(&mut track, subscription); + Self::run_raw(on_frame, track, close, updates).await })) .await; @@ -688,9 +600,22 @@ impl Consume { Ok(id) } + /// Apply a raw reader's preferences, which join at the latest group unless a range + /// names a floor of its own. + fn apply_raw_subscription(track: &mut moq_net::track::Ordered, subscription: Option) { + let mut subscription = subscription.unwrap_or_default(); + for range in &mut subscription.ranges { + if range.start == moq_net::track::Range::oldest().start { + range.start = moq_net::track::Range::live().start; + } + } + // A closed track makes the update meaningless; the reader already sees the close. + let _ = track.update(subscription); + } + async fn run_raw( callback: OnStatus, - mut reader: RawReader, + mut track: moq_net::track::Ordered, mut close: oneshot::Receiver<()>, mut updates: mpsc::UnboundedReceiver>, ) -> Result<(), Error> { @@ -704,14 +629,10 @@ impl Consume { loop { let mut group = match moq_net::kio::wait(|waiter| -> Poll, Error>> { - if Self::poll_raw_control(&mut close, &mut updates, &mut reader, waiter) { + if Self::poll_raw_control(&mut close, &mut updates, &mut track, waiter) { return Poll::Ready(Ok(RawStep::Stop)); } - // A group read before deferred bounds apply could sit under the old floor. - if reader.deferred.is_some() { - return Poll::Pending; - } - match ready!(reader.track.poll_next_group(waiter))? { + match ready!(track.poll_next_group(waiter))? { Some(group) => Poll::Ready(Ok(RawStep::Item(group))), None => Poll::Ready(Ok(RawStep::End)), } @@ -725,7 +646,7 @@ impl Consume { loop { let frame = match moq_net::kio::wait(|waiter| -> Poll, Error>> { - if Self::poll_raw_control(&mut close, &mut updates, &mut reader, waiter) { + if Self::poll_raw_control(&mut close, &mut updates, &mut track, waiter) { return Poll::Ready(Ok(RawStep::Stop)); } match ready!(group.poll_read_frame(waiter))? { @@ -749,18 +670,17 @@ impl Consume { } } - /// Poll the close and update channels, applying any subscription updates inline, and a - /// deferred update once its edge is live. + /// Poll the close and update channels, applying any subscription updates inline. /// /// Returns `true` when the task must stop: either the consumer was closed /// (`close` fired) or the update channel was dropped. The caller must then /// unwind rather than poll `close` again, since a completed oneshot panics if - /// re-polled. Borrows `reader` only for the duration of the call so the caller + /// re-polled. Borrows `track` only for the duration of the call so the caller /// can poll a track/group source afterwards. fn poll_raw_control( close: &mut oneshot::Receiver<()>, updates: &mut mpsc::UnboundedReceiver>, - reader: &mut RawReader, + track: &mut moq_net::track::Ordered, waiter: &moq_net::kio::Waiter, ) -> bool { let mut cx = waiter.context(); @@ -770,13 +690,11 @@ impl Consume { loop { match updates.poll_recv(&mut cx) { - Poll::Ready(Some(subscription)) => reader.apply(subscription, waiter), + Poll::Ready(Some(subscription)) => Self::apply_raw_subscription(track, subscription), Poll::Ready(None) => return true, - Poll::Pending => break, + Poll::Pending => return false, } } - reader.poll_deferred(waiter); - false } pub fn raw_track_update( diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 2d09c4a2fe3..c71da0c5e19 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,10 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover, rather than starting at the lowest one and taking whatever arrives. + ### Track demand reports the wanted group (#5930) `MoqTrackDemand::is_used`, `used`, and `unused` are gone. diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index da139c126d8..728f08e1999 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -38,9 +38,8 @@ pub struct MoqSubscription { /// The spans of the track to deliver, or null for everything `max_delay_us` still /// considers fresh. An empty list asks for nothing. /// - /// A request to the publisher, aggregated across every subscriber. The one place it - /// touches this subscription's reads is where they start: at the lowest group any - /// range starts at. + /// A request to the publisher, aggregated across every subscriber, and this + /// subscription's read filter: it reads only the groups its own ranges cover. #[uniffi(default = None)] pub ranges: Option>, /// Which pending group the publisher sends first, or null for newest first. diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 6683bbfad81..40251d198b6 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -175,17 +175,7 @@ impl Consumer { // edge from there. The budget is clamped to the track's retention window, // since history the publisher no longer keeps can't be waited for and would // otherwise stall the catch-up by the excess. - // Only absolute starts name a floor: a relative one counts back from wherever the - // publisher is, which the first arrivals say. - let start = subscription - .ranges - .iter() - .map(|range| match range.start { - moq_net::track::Anchor::Absolute(start) => Some(start.group), - moq_net::track::Anchor::Relative(_) => None, - }) - .collect::>>() - .and_then(|starts| starts.into_iter().min()); + let start = track.floor(); let max_delay = subscription .max_delay .min(track.info().max_age.unwrap_or(std::time::Duration::MAX)); @@ -326,6 +316,15 @@ impl Consumer { // (a gap below the served window looks like in-flight groups until the budget // expires, or forever on a quiet track), so the cursor adopts it from the // first arrivals instead of assuming group 0. Skipping stays the budget's job. + // A relative start names its floor once the first group to arrive resolves it. + if self.startup + && !self.newest + && let Some(start) = self.subscriber().floor() + { + self.pending.retain(|group| group.sequence >= start); + self.current = start; + self.startup = false; + } if self.startup { // NOTE: poll_min_timestamp buffers at least one frame per group and // registers the waiter on the ones still empty. Any wire frame counts, so a @@ -510,7 +509,9 @@ impl Consumer { // the gap outright, since no new group can arrive. That proof covers only // sequences that never arrived: finishing the track ends new groups, not // the frames still flowing on ones already open, so arrived groups are - // settled below by their own FIN, abort, or the budget. + // settled below by their own FIN, abort, or the budget. Sequences the + // subscriber's own ranges exclude never arrive either, so a gap between two + // ranges closes at once. if let Some(front) = self.pending.front() && front.sequence > self.current { @@ -521,7 +522,11 @@ impl Consumer { let since = self.hole.unwrap().1; let elapsed = since.elapsed() >= self.max_delay; let aged = next_group.is_some_and(|(_, start)| max_timestamp.saturating_sub(start) >= self.max_delay); - if finished || elapsed || aged { + let excluded = self + .track + .as_ref() + .is_some_and(|track| track.next_in_range(self.current).is_none_or(|next| next >= sequence)); + if finished || elapsed || aged || excluded { let _ = self.pending.front_mut().unwrap().look_all(waiter, &self.format); let front = self.pending.front_mut().unwrap(); let empty = front.empty && matches!(front.group.poll_finished(waiter), Poll::Ready(Ok(_))); @@ -3341,6 +3346,63 @@ mod tests { assert_eq!(micros, vec![0, 100_000], "the open group's late frames are delivered"); } + /// A gap between two ranges is never read, so the reader walks over it at once rather + /// than waiting a budget, or forever on an open track, for groups it never gets. + #[tokio::test] + async fn an_excluded_gap_between_ranges_does_not_stall() { + tokio::time::pause(); + let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); + let subscription = moq_net::track::Subscription::default() + .with_max_delay(Duration::from_secs(10)) + .with_ranges([ + moq_net::track::Range::groups(0..1), + moq_net::track::Range::groups(10..11), + ]); + let mut consumer = Consumer::new( + track.subscribe(subscription), + Container::Legacy(crate::container::Kind::Data), + ); + for sequence in [0, 5, 10] { + write_group(&mut track, sequence, &[ts(sequence * 100_000)]); + } + + let mut micros = Vec::new(); + for _ in 0..2 { + let frame = tokio::time::timeout(Duration::from_millis(200), consumer.read_frame()) + .await + .expect("the open track still delivers the later range") + .unwrap() + .expect("a frame"); + micros.push(frame.timestamp.as_micros()); + } + assert_eq!(micros, vec![0, 1_000_000]); + } + + /// A relative start names its floor once resolved: delivery starts that many groups + /// back from the latest, rather than at whichever group arrives first. + #[tokio::test] + async fn startup_at_a_relative_start() { + tokio::time::pause(); + let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); + for sequence in 5..=9 { + write_group(&mut track, sequence, &[ts(sequence * 100_000)]); + } + let subscription = moq_net::track::Subscription::default() + .with_max_delay(Duration::from_secs(10)) + .with_ranges([moq_net::track::Range { + start: moq_net::track::Anchor::Relative(2), + end: None, + }]); + let consumer_track = track.subscribe(subscription); + assert_eq!(consumer_track.floor(), Some(7)); + let mut consumer = Consumer::new(consumer_track, Container::Legacy(crate::container::Kind::Data)); + track.finish().unwrap(); + + let frames = read_all(&mut consumer).await.unwrap(); + let micros: Vec = frames.iter().map(|f| f.timestamp.as_micros()).collect(); + assert_eq!(micros, vec![700_000, 800_000, 900_000]); + } + #[tokio::test] async fn startup_single_group_mid_stream() { let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index bda4b7f1efc..dead14e4b55 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter each subscriber's reads (#5929) + +A `track::Subscriber` now reads only the groups its own `Subscription::ranges` cover, resolved again whenever `update` replaces them; before, they only set where its reads started. +A reader that relied on groups another subscriber asked for must widen its own ranges. +Once a track finishes, a subscriber with nothing left under its `set_groups` cap ends rather than waiting for the cap to rise; raise it before the track finishes to read past it. + ### `largest()` reports only upstream state, within a reader's `set_groups` bounds `track::Subscriber::largest()` and `track::Ordered::largest()` (and their `poll_largest`) report the track's largest only while it is within the bounds set with `set_groups`, where they used to ignore the bounds. diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index 73756c0f415..5d34818d408 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -56,6 +56,15 @@ //! disjoint range, swept over how many ranges and how many groups each spans: reading the //! union must cost the same per range whatever it spans. //! +//! `track_range_serving` drains one reader whose own ranges are disjoint, swept over how +//! many ranges and how many cached groups each spans: each group handed out must cost the +//! same, so a walk over the ranges or the groups between them shows up as a slope. +//! +//! `track_range_widening` widens one reader's ranges by a fixed span of cached groups and +//! back, swept over the groups cached and the readers beside it: a reader holds nothing +//! for the groups outside its ranges, so a widening scans only the span it newly covers, +//! and a scan of the cache or the audience 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. @@ -107,6 +116,17 @@ const RANGES: [u64; 4] = [1, 16, 256, 4096]; /// Groups each aggregated range spans. const SPAN: [u64; 3] = [1, 64, 4096]; +/// Disjoint ranges one reader is served, and the cached groups each spans. +const SERVED_RANGES: [u64; 3] = [1, 16, 256]; +const SERVED_SPAN: [u64; 3] = [1, 8, 64]; + +/// Readers beside the one widening, and the groups cached around the span it widens by. +const WIDENING_READERS: [usize; 3] = [1, 64, 1024]; +const WIDENING_CACHED: [u64; 3] = [64, 1024, 16_384]; + +/// Cached groups one widening newly covers. +const WIDENED: u64 = 8; + /// Concurrent publishers sharing one relay-style cache pool. const WRITERS: [usize; 4] = [1, 2, 4, 8]; @@ -811,6 +831,48 @@ fn bench_subscription_ranges(c: &mut Criterion) { group.finish(); } +fn bench_range_serving(c: &mut Criterion) { + let mut group = c.benchmark_group("track_range_serving"); + for span in SERVED_SPAN { + for ranges in SERVED_RANGES { + let broadcast = broadcast::Info::default().produce(); + let track = broadcast.create_track("bench", None).unwrap(); + // A gap between neighbors keeps every range distinct, and its group cached. + for _ in 0..ranges * (span + 1) { + let mut group = track.append_group().unwrap(); + group.write_frame(None, Bytes::from_static(b"x")).unwrap(); + group.finish().unwrap(); + } + let subscription = track::Subscription::default() + .with_max_delay(AGED_BUDGET) + .with_ranges((0..ranges).map(|i| { + let start = i * (span + 1); + track::Range::groups(start..start + span) + })); + + group.throughput(Throughput::Elements(ranges * span)); + group.bench_function(BenchmarkId::new(format!("span_{span}"), ranges), |b| { + b.iter_batched( + || track.subscribe(subscription.clone()), + |mut reader| { + let waiter = kio::Waiter::noop(); + let mut served = 0; + while let Poll::Ready(Ok(Some(group))) = reader.poll_recv_group(&waiter) { + black_box(group); + served += 1; + } + assert_eq!(served, ranges * span); + reader + }, + BatchSize::SmallInput, + ); + }); + drop((track, broadcast)); + } + } + group.finish(); +} + /// Late joins scan a bounded datagram ring; sweep both the backlog and the joining audience. fn bench_datagram_subscriber_join(c: &mut Criterion) { let mut group = c.benchmark_group("track_datagram_subscriber_join"); @@ -1152,47 +1214,47 @@ fn bench_empty_run(c: &mut Criterion) { group.finish(); } -/// Abort one non-prefix parked group, swept over backlog and capped subscribers. -fn bench_parked_abort(c: &mut Criterion) { - let mut bench = c.benchmark_group("track_parked_abort"); - bench.sample_size(10); - for readers in [1, 8, 64] { - for retained in [1u64, 64, 1024] { - bench.bench_function(BenchmarkId::new(readers.to_string(), retained), |b| { - b.iter_batched_ref( - || { - let broadcast = broadcast::Producer::new(Default::default()); - let track = broadcast.create_track("parked", timed()).unwrap(); - let mut subscribers: Vec<_> = (0..readers) - .map(|_| { - let mut subscriber = - track.subscribe(track::Subscription::default().with_max_delay(AGED_BUDGET)); - subscriber.set_groups(..0); - subscriber - }) - .collect(); - let groups: Vec<_> = (0..retained) - .map(|sequence| { - let mut group = track.create_group(moq_net::group::Info { sequence }).unwrap(); - group.write_frame(Timestamp::ZERO, b"f".as_ref()).unwrap(); - group.clone().finish().unwrap(); - group - }) - .collect(); - for subscriber in &mut subscribers { - assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); - } - (broadcast, track, subscribers, groups) - }, - |(_, _, subscribers, groups)| { - groups[retained as usize / 2].clone().abort(Error::Old).unwrap(); - for subscriber in subscribers { - let res = subscriber.poll_recv_group(&kio::Waiter::noop()); - assert!(res.is_pending()); - } - }, - BatchSize::PerIteration, - ); +/// One reader widening its ranges by [`WIDENED`] cached groups mid-cache and back, beside +/// idle narrow readers. Every widening after the first finds groups already handed out, +/// which the reader skips, so each one costs the same scan of the span. +fn bench_range_widening(c: &mut Criterion) { + let mut bench = c.benchmark_group("track_range_widening"); + bench.throughput(Throughput::Elements(1)); + for readers in WIDENING_READERS { + for cached in WIDENING_CACHED { + bench.bench_function(BenchmarkId::new(format!("{readers}r"), cached), |b| { + let broadcast = broadcast::Info::default().produce(); + let track = broadcast.create_track("bench", None).unwrap(); + for _ in 0..cached { + let mut group = track.append_group().unwrap(); + group.write_frame(None, Bytes::from_static(b"x")).unwrap(); + group.finish().unwrap(); + } + let narrow = track::Subscription::default() + .with_max_delay(AGED_BUDGET) + .with_groups(0..1); + let mid = cached / 2; + let wide = narrow + .clone() + .with_ranges([track::Range::groups(0..1), track::Range::groups(mid..mid + WIDENED)]); + let waiter = kio::Waiter::noop(); + let mut readers: Vec<_> = (0..readers) + .map(|_| { + let mut reader = track.subscribe(narrow.clone()); + while let Poll::Ready(Ok(Some(_))) = reader.poll_recv_group(&waiter) {} + reader + }) + .collect(); + let reader = &mut readers[0]; + b.iter(|| { + reader.update(wide.clone()).unwrap(); + while let Poll::Ready(Ok(Some(group))) = reader.poll_recv_group(&waiter) { + black_box(group); + } + reader.update(narrow.clone()).unwrap(); + assert!(reader.poll_recv_group(&waiter).is_pending()); + }); + drop((readers, track, broadcast)); }); } } @@ -1201,7 +1263,7 @@ fn bench_parked_abort(c: &mut Criterion) { criterion_group!( benches, - bench_parked_abort, + bench_range_widening, bench_fanout, bench_fanout_frame, bench_fetch_expiry, @@ -1213,6 +1275,7 @@ criterion_group!( bench_subscriber_join, bench_range_demand, bench_subscription_ranges, + bench_range_serving, bench_subscriber_preferences, bench_datagram_subscriber_join, bench_parked_read, diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 0e1ba4f002c..34a497e5927 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -5258,9 +5258,9 @@ mod serve_group_test { drop(track); run.await.unwrap(); } - // Group 2 arrived before the subscription and the cursor has passed it, so it was - // never served either. - assert_eq!(drops(&mut writer, &log, version).await, [(0, 4), (6, 6)]); + // Group 2 arrived before the subscription, but it is cached, so the lowered start + // serves it and the drops name only what is missing around it. + assert_eq!(drops(&mut writer, &log, version).await, [(0, 1), (3, 4), (6, 6)]); } /// A lowered start restarts the age of the gaps it newly asks for, as the subscriber does, @@ -5602,10 +5602,10 @@ mod serve_group_test { assert_eq!(relay.opened(), 1, "the finished group is the live edge"); } - /// Widening the floor never rewinds past a group already served: group 0 - /// arrived before the served groups, so the cursor has moved past it. + /// Widening the floor serves a cached group below the old one, even though it arrived + /// before the groups already served, and serves those only once. #[moq_clock::test] - async fn a_widening_update_does_not_rewind_past_served_groups() { + async fn a_widening_update_serves_a_cached_group_below_the_old_floor() { let mut track = track::Producer::new( Arc::new(broadcast::Info::default()), "test", @@ -5630,7 +5630,7 @@ mod serve_group_test { end_frame: None, }); relay.settle(); - assert_eq!(relay.opened(), 2, "group 0 was passed, not skipped"); + assert_eq!(relay.opened(), 3, "group 0 is cached and now in range"); } /// A SUBSCRIBE_UPDATE landing while the first group waits on the source's start keeps diff --git a/rs/moq-net/src/model/aborts.rs b/rs/moq-net/src/model/aborts.rs index 162ddedb3ed..aa9995f7e2f 100644 --- a/rs/moq-net/src/model/aborts.rs +++ b/rs/moq-net/src/model/aborts.rs @@ -1,40 +1,25 @@ -//! Abort notifications for the groups each capped subscriber parks, and for every -//! producer cut a wire publisher forwards as a drop. +//! Notifications of every producer cut a wire publisher forwards as a drop. use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; -/// One registration per subscriber, with pending sequences bounded by its parked set, -/// or by the cuts between two polls. +/// One registration per wire publisher, with pending sequences bounded by the cuts +/// between two polls. #[derive(Default)] pub(super) struct Aborts { - // Visit only active cursors, even after a larger fanout has disconnected. + // Visit only active watchers, even after a larger fanout has disconnected. readers: kio::Lock>>, } #[derive(Default)] struct State { - // Notified of every cut, whatever `watched` holds. - cuts: bool, - watched: BTreeSet, pending: BTreeSet, waiters: kio::WaiterList, } impl Aborts { - pub(super) fn watch(self: &Arc) -> Watch { - self.register(State::default()) - } - pub(super) fn watch_cuts(self: &Arc) -> Watch { - self.register(State { - cuts: true, - ..State::default() - }) - } - - fn register(self: &Arc, state: State) -> Watch { - let state = kio::Lock::new(state); + let state = kio::Lock::new(State::default()); let mut readers = self.readers.lock(); let key = readers .last_key_value() @@ -47,13 +32,14 @@ impl Aborts { } } - pub(super) fn notify(&self, sequence: u64, cut: bool) { + /// Tell every watcher that the publisher cut group `sequence`. + pub(super) fn notify(&self, sequence: u64) { let mut wakes = Vec::new(); { let readers = self.readers.lock(); for reader in readers.values() { let mut state = reader.lock(); - if ((cut && state.cuts) || state.watched.contains(&sequence)) && state.pending.insert(sequence) { + if state.pending.insert(sequence) { wakes.push(state.waiters.take()); } } @@ -72,26 +58,6 @@ pub(super) struct Watch { } impl Watch { - pub(super) fn insert(&self, sequence: u64) { - self.state.lock().watched.insert(sequence); - } - - pub(super) fn remove(&self, sequence: u64) { - let mut state = self.state.lock(); - state.watched.remove(&sequence); - state.pending.remove(&sequence); - } - - pub(super) fn register(&self, waiter: &kio::Waiter) { - let mut state = self.state.lock(); - state.waiters.register(waiter); - let pending = !state.pending.is_empty(); - drop(state); - if pending { - waiter.waker().wake_by_ref(); - } - } - pub(super) fn poll(&self, waiter: &kio::Waiter) -> BTreeSet { let mut state = self.state.lock(); state.waiters.register(waiter); @@ -120,39 +86,16 @@ mod tests { } #[test] - fn registering_after_an_abort_still_wakes() { + fn a_cut_wakes_the_watcher_once_per_poll() { let registry = Arc::new(Aborts::default()); - let watch = registry.watch(); - watch.insert(7); - registry.notify(7, false); + let watch = registry.watch_cuts(); let counter = Arc::new(Counter::default()); let waiter = kio::Waiter::new(Waker::from(counter.clone())); - watch.register(&waiter); + assert!(watch.poll(&waiter).is_empty()); + registry.notify(2); + registry.notify(3); assert_eq!(counter.0.load(Ordering::SeqCst), 1); - assert_eq!(watch.poll(&waiter), BTreeSet::from([7])); - } - - #[test] - fn a_cuts_watch_sees_every_cut_and_nothing_else() { - let registry = Arc::new(Aborts::default()); - let watch = registry.watch_cuts(); - registry.notify(1, false); - registry.notify(2, true); - assert_eq!(watch.poll(&kio::Waiter::noop()), BTreeSet::from([2])); - } - - #[test] - fn notifications_remain_bounded_by_watched_groups() { - let registry = Arc::new(Aborts::default()); - let watch = registry.watch(); - watch.insert(3); - for sequence in 0..1024 { - registry.notify(sequence, false); - } - assert_eq!(watch.poll(&kio::Waiter::noop()), BTreeSet::from([3])); - registry.notify(3, false); - watch.remove(3); - assert!(watch.poll(&kio::Waiter::noop()).is_empty()); + assert_eq!(watch.poll(&waiter), BTreeSet::from([2, 3])); drop(watch); assert!(registry.readers.lock().is_empty()); } diff --git a/rs/moq-net/src/model/cache.rs b/rs/moq-net/src/model/cache.rs index 40f9e5cf9d9..13d42ee35c8 100644 --- a/rs/moq-net/src/model/cache.rs +++ b/rs/moq-net/src/model/cache.rs @@ -819,19 +819,15 @@ impl Track { &self.wakes } - pub(super) fn watch_aborts(&self) -> aborts::Watch { - self.aborts.get_or_init(Arc::default).watch() - } - /// Watch every group the publisher cuts, rather than chosen sequences. pub(super) fn watch_cuts(&self) -> aborts::Watch { self.aborts.get_or_init(Arc::default).watch_cuts() } - /// Notify watchers that group `sequence` aborted, and whether the publisher cut it. + /// Notify watchers that group `sequence` aborted, if the publisher cut it. pub(super) fn aborted(&self, sequence: u64, cut: bool) { - if let Some(aborts) = self.aborts.get() { - aborts.notify(sequence, cut); + if cut && let Some(aborts) = self.aborts.get() { + aborts.notify(sequence); } } diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 00ccab6be24..8fc2bb600dc 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -2154,12 +2154,6 @@ impl Consumer { self.cursor.expiry_pending() } - /// Whether the group has been aborted (including pool eviction); the abort - /// dropped the cached frames, so a held consumer has nothing left to read. - pub(super) fn is_aborted(&self) -> bool { - self.cursor.state.read().abort.is_some() - } - /// Mark the group as still being read, so a slow batch drain does not expire it. pub fn keep_alive(&self) { self.cursor.state.read().charge.refresh(); diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index fd95ba87c44..bbd3762b759 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -27,7 +27,7 @@ use std::task::{Poll, ready}; use crate::{Error, Result, StreamError, group, stats, track}; use super::delivered::Delivered; -use super::subscription::{Cap, Position, Subscription}; +use super::subscription::{Anchor, Cap, Position, Resolved, Subscription, Window}; /// What the front says about a logical track: the copy to read now, and how it ended. #[derive(Default)] @@ -173,9 +173,10 @@ impl Consumer { /// Subscribe to the logical track with the reader's own preferences. pub(super) fn subscribe(&self, subscription: track::Preferences) -> Subscriber { let mirrored = subscription.read().clone(); + let window = Window::new(&mirrored.ranges, None); // An absolute floor holds from the start, as on a plain cursor; a relative one // counts back from an edge, so the reported largest is never below it. - let floor = mirrored.floor(None).unwrap_or(0); + let floor = window.floor().unwrap_or(0); let reader = Arc::new_cyclic(|this| { Mutex::new(Reader { this: this.clone(), @@ -185,6 +186,7 @@ impl Consumer { generation: None, copies: Vec::new(), newest: None, + window, datagram: None, groups: (floor, Cap::from(..)), groups_read: false, @@ -282,6 +284,11 @@ struct Copy { sub: Sub, /// Where the subscription was asked to start, so demand updates never ask for less. floor: Option, + /// The reader's ranges [`Self::resolved`] are for. + from: Vec, + /// Each of those ranges resolved, by index, so clamping never widens one: as the + /// serving copy had them when this one was subscribed, until this one answers. + resolved: Vec>, /// Replaced by a newer route: its datagrams are the newer copy's to deliver. replaced: bool, /// Bounded once the newer copy delivered its first group: nothing past this group is @@ -308,8 +315,23 @@ enum Sub { impl Copy { /// What this copy is asked for: the reader's own preferences, from no earlier than /// where it was asked to start, and through no later than where it was replaced. - fn wire(&self, reader: &Subscription) -> Subscription { - reader.clamped(self.floor, self.end().map(Position::group)) + fn wire(&mut self, reader: &Subscription) -> Subscription { + match &mut self.sub { + // Its own resolution, which capping its end must not move. + Sub::Ready(sub) if reader.ranges == self.from => self.resolved = sub.resolved_ranges(), + // Replaced ranges resolve against this copy's cache now, as any subscriber's do. + Sub::Ready(sub) => { + self.resolved = sub.resolve_ranges(&reader.ranges); + self.from = reader.ranges.clone(); + } + Sub::Pending(_) if reader.ranges == self.from => {} + // Its own resolution, once it answers, is for these. + Sub::Pending(_) => { + self.resolved.clear(); + self.from = reader.ranges.clone(); + } + } + reader.clamped(self.floor, self.end().map(Position::group), &self.resolved) } /// The first group past where this copy was replaced, if any: one replaced before @@ -331,7 +353,8 @@ impl Copy { fn poll_ready(&mut self, groups: (u64, Cap), waiter: &kio::Waiter) -> Poll> { if let Sub::Pending(pending) = &self.sub { let mut sub = ready!(pending.poll_ok(waiter))?; - sub.raise_start_to(groups.0); + // An anchor the wire kept may resolve below where the reader got to. + sub.raise_start_to(self.floor.map_or(groups.0, |floor| groups.0.max(floor.group))); sub.end_at(groups.1); self.sub = Sub::Ready(Box::new(sub)); } @@ -415,6 +438,17 @@ impl Subscriber { } } + /// The lowest group the reader's ranges name; see [`track::Subscriber::floor`]. + pub(super) fn floor(&self) -> Option { + self.reader().window().start() + } + + /// The lowest group at or above `from` the reader's ranges cover; see + /// [`track::Subscriber::next_in_range`]. + pub(super) fn next_in_range(&self, from: u64) -> Option { + self.reader().window().runs(from).next().map(|run| *run.start()) + } + /// Poll for sequences the serving copy gave up; see [`track::Subscriber::poll_drop`]. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(super) fn poll_drop(&mut self, waiter: &kio::Waiter) -> Poll> { @@ -515,6 +549,8 @@ struct Reader { copies: Vec, /// The newest group handed out: where a new copy is asked to start. newest: Option, + /// The reader's ranges resolved; see [`Reader::window`]. + window: Window, /// The newest datagram handed out, by Group and Object ID. datagram: Option<(u64, u64)>, /// The local group filter; see [`track::Subscriber::set_groups`]. @@ -524,6 +560,56 @@ struct Reader { } impl Reader { + /// The reader's ranges resolved, a relative anchor as the first ready copy to answer for + /// them resolved it, whatever its failover cap: that bounds what the copy delivers, not + /// where the ranges resolve. Kept once resolved, past that copy being let go. Pending + /// while a relative anchor waits on a copy to resolve it. + fn window(&mut self) -> &Window { + for index in 0..self.copies.len() { + if !self.window.is_pending() { + break; + } + let copy = &mut self.copies[index]; + let resolved = match &mut copy.sub { + Sub::Ready(sub) if copy.from == self.mirrored.ranges => sub.resolved_ranges(), + _ => continue, + }; + self.resolve(&resolved); + } + &self.window + } + + /// Resolve a pending [`Self::window`] with a copy's resolution of the reader's ranges, + /// unless a relative anchor still waits there too. + fn resolve(&mut self, resolved: &[Option]) { + let ranges: Vec<_> = self + .mirrored + .ranges + .iter() + .enumerate() + .map(|(index, range)| { + let Some(resolved) = resolved.get(index).copied().flatten() else { + return *range; + }; + track::Range { + start: match range.start { + // The farthest offset reaches back to the oldest group, which names no floor. + Anchor::Relative(back) if back != u64::MAX => Anchor::Absolute(Position::group(resolved.start)), + start => start, + }, + end: match (range.end, resolved.end) { + (Some(Anchor::Relative(_)), Some(end)) => Some(Anchor::Absolute(Position::group(end))), + (end, _) => end, + }, + } + }) + .collect(); + let window = Window::new(&ranges, None); + if !window.is_pending() { + self.window = window; + } + } + /// Follow the route: subscribe to a new copy, mark the ones it replaced, and mirror /// the reader's preferences into each. fn sync(&mut self, waiter: &kio::Waiter) { @@ -537,10 +623,27 @@ impl Reader { Poll::<()>::Pending }); if let Some(sub) = changed { + let replaced = sub.ranges != self.mirrored.ranges; + if replaced { + self.window = Window::new(&sub.ranges, None); + } self.mirrored = sub; for copy in &mut self.copies { copy.update(&self.mirrored); } + // A ready copy just resolved the replaced ranges against its cache, before its + // failover cap raised them, so the reader takes that resolution now. + if replaced { + for index in (0..self.copies.len()).rev() { + if !self.window.is_pending() { + break; + } + if matches!(self.copies[index].sub, Sub::Ready(_)) { + let resolved = self.copies[index].resolved.clone(); + self.resolve(&resolved); + } + } + } } let (generation, serving) = { @@ -555,6 +658,23 @@ impl Reader { if self.copies.last().is_some_and(|copy| copy.generation == generation) { return; } + // Where the serving copy resolved the reader's ranges: the floor raises each start + // there, never lowering a later range to the floor. + let resolved = match self.copies.last_mut() { + Some(Copy { + sub: Sub::Ready(sub), + from, + .. + }) if *from == self.mirrored.ranges => sub.resolved_ranges(), + // One still unanswered passes on what it was handed. + Some(Copy { + sub: Sub::Pending(_), + from, + resolved, + .. + }) if *from == self.mirrored.ranges => resolved.clone(), + _ => Vec::new(), + }; // The replaced copies keep delivering until the new one delivers its first group: a // group they get meanwhile may be one the new route never has. See `observe`. for copy in &mut self.copies { @@ -563,12 +683,14 @@ impl Reader { let floor = self.newest.map(Position::group); // The wire request carries the floor from the first message: some sessions read // the demand only once. - let wire = self.mirrored.clamped(floor, None); + let wire = self.mirrored.clamped(floor, None, &resolved); self.copies.push(Copy { generation, sub: Sub::Pending(track.subscribe(wire)), track, floor, + from: self.mirrored.ranges.clone(), + resolved, replaced: false, until: None, done: None, @@ -2183,6 +2305,159 @@ mod test { assert_eq!(recv(&mut sub).sequence, 6); } + /// A replacement copy keeps a later range disjoint: the floor raises nothing above it, + /// and capping the replaced copy never widens its end. + #[test] + #[cfg(feature = "async")] + fn a_replacement_keeps_a_later_range_disjoint() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + let ranges = [track::Range::groups(0..1), track::Range::groups(10..12)]; + let subscription = Subscription::default() + .with_max_delay(Duration::from_secs(30)) + .with_ranges(ranges); + let mut sub = logical + .consume() + .subscribe(subscription) + .now_or_never() + .unwrap() + .unwrap(); + for sequence in 0..13 { + let mut group = a.create_group(group::Info { sequence }).unwrap(); + group.write_frame(ts(sequence * 1000), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + } + assert_eq!(recv(&mut sub).sequence, 0); + + let b = copy(); + routes.serve(b.consume()); + // The read subscribes the replacement while the replaced copy still serves. + let mut rest = Vec::new(); + while let Some(Ok(Some(group))) = sub.recv_group().now_or_never() { + rest.push(group.sequence); + } + for sequence in 0..13 { + let mut group = b.create_group(group::Info { sequence }).unwrap(); + group.write_frame(ts(sequence * 1000), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + } + while let Some(Ok(Some(group))) = sub.recv_group().now_or_never() { + rest.push(group.sequence); + } + rest.sort(); + assert_eq!(rest, vec![10, 11]); + } + + /// A replacement updated to a relative range it cannot resolve yet asks for that range, + /// not everything from the floor: before it answers, and once it answers with nothing + /// to resolve it against. + #[test] + fn a_replacement_keeps_an_updated_relative_start() { + let routes = Producer::new(); + let a = copy(); + routes.serve(a.consume()); + let sub = routes + .consume() + .subscribe(kio::Producer::new(Subscription::default()).into()); + let mut reader = sub.reader.lock().unwrap(); + let copy = &mut reader.copies[0]; + copy.floor = Some(Position::group(0)); + let ranges = vec![track::Range { + start: track::Anchor::Relative(3), + end: None, + }]; + let updated = Subscription::default().with_ranges(ranges.clone()); + assert!(matches!(copy.sub, Sub::Pending(_))); + copy.update(&updated); + assert_eq!(copy.wire(&updated).ranges, ranges); + assert!(copy.poll_ready((0, Cap::from(..)), &kio::Waiter::noop()).is_ready()); + assert_eq!(copy.wire(&updated).ranges, ranges); + } + + /// A replacement asked for a range reaching back past where the reader got to, while + /// still unanswered, never hands out a group the reader skipped below that point. + #[test] + #[cfg(feature = "async")] + fn a_pending_replacement_never_reads_below_the_floor() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + let mut sub = subscribe(&logical, Duration::from_secs(30)); + let mut five = a.create_group(group::Info { sequence: 5 }).unwrap(); + five.write_frame(ts(5000), b"x".as_ref()).unwrap(); + assert_eq!(recv(&mut sub).sequence, 5); + + let b = track::Request::new(Arc::new(broadcast::Info::default()), "video"); + routes.serve(b.consume()); + // The read subscribes the replacement, which has not answered yet. + assert!(sub.recv_group().now_or_never().is_none()); + let back = sub.subscription().with_ranges([track::Range { + start: track::Anchor::Relative(100), + end: None, + }]); + sub.update(back).unwrap(); + assert!(sub.recv_group().now_or_never().is_none()); + + let b = b.accept(crate::track::Info::timed()); + for sequence in 0..7 { + let mut group = b.create_group(group::Info { sequence }).unwrap(); + group.write_frame(ts(sequence * 1000), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + } + let mut rest = Vec::new(); + while let Some(Ok(Some(group))) = sub.recv_group().now_or_never() { + rest.push(group.sequence); + } + assert_eq!(rest, vec![6]); + } + + /// A copy updated while pending, then answering, hands its own resolution of the + /// updated ranges to the copy replacing it, rather than leaving the replacement to + /// resolve them from whatever it caches. + #[test] + #[cfg(feature = "async")] + fn a_copy_updated_while_pending_passes_on_its_resolution() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + let mut sub = subscribe(&logical, Duration::from_secs(30)); + let mut zero = a.create_group(group::Info { sequence: 0 }).unwrap(); + zero.write_frame(ts(0), b"x".as_ref()).unwrap(); + assert_eq!(recv(&mut sub).sequence, 0); + + let b = track::Request::new(Arc::new(broadcast::Info::default()), "video"); + routes.serve(b.consume()); + // The read subscribes the replacement, which has not answered yet. + assert!(sub.recv_group().now_or_never().is_none()); + let ranges = [track::Range { + start: track::Anchor::Relative(2), + end: None, + }]; + sub.update(sub.subscription().with_ranges(ranges)).unwrap(); + assert!(sub.recv_group().now_or_never().is_none()); + + let b = b.accept(crate::track::Info::timed()); + for sequence in 0..13 { + let mut group = b.create_group(group::Info { sequence }).unwrap(); + group.write_frame(ts(sequence * 1000), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + } + while let Some(Ok(Some(_))) = sub.recv_group().now_or_never() {} + + let c = copy(); + routes.serve(c.consume()); + assert!(sub.recv_group().now_or_never().is_none()); + let start = c.subscription().expect("subscribed").ranges[0].start; + assert!( + matches!(start, track::Anchor::Absolute(_)), + "the replacement asks from where b resolved the relative start: {start:?}" + ); + } + #[test] fn a_replaced_copy_at_the_last_sequence_has_no_overflow_cap() { for (last, end) in [(Some(u64::MAX), None), (None, Some(Position::default()))] { @@ -3175,6 +3450,125 @@ mod test { } } + /// A relative start, as the first copy resolved it, stays once a route switch lets that + /// copy go. + #[test] + #[cfg(feature = "async")] + fn the_floor_outlives_the_first_copy() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + write(&a, 4); + write(&a, 5); + let subscription = Subscription::default() + .with_max_delay(Duration::from_secs(10)) + .with_ranges([track::Range { + start: Anchor::Relative(1), + end: None, + }]); + let mut sub = logical + .consume() + .subscribe(subscription) + .now_or_never() + .unwrap() + .unwrap(); + let mut read = drain(&mut sub, false); + read.sort(); + assert_eq!(read, [4, 5]); + assert_eq!(sub.floor(), Some(4)); + assert_eq!(sub.next_in_range(0), Some(4)); + + let b = copy(); + routes.serve(b.consume()); + write(&b, 6); + assert_eq!(drain(&mut sub, false), [6]); + let readers = routes.readers.lock().unwrap(); + let reader = readers[0].upgrade().unwrap(); + assert_eq!(reader.lock().unwrap().copies.len(), 1, "the first copy was let go"); + drop(readers); + assert_eq!(sub.floor(), Some(4)); + assert_eq!(sub.next_in_range(0), Some(4)); + } + + /// Ranges replaced once a route switch let the first copy go still resolve, from the + /// serving copy, whose failover cap bounds what it delivers and not where they resolve. + #[test] + #[cfg(feature = "async")] + fn replaced_ranges_resolve_after_the_first_copy_is_let_go() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + let mut sub = subscribe(&logical, Duration::from_secs(10)); + write(&a, 0); + assert_eq!(drain(&mut sub, false), [0]); + + let b = copy(); + routes.serve(b.consume()); + for sequence in 1..=5 { + write(&b, sequence); + } + assert!(!drain(&mut sub, false).is_empty()); + { + let readers = routes.readers.lock().unwrap(); + let reader = readers[0].upgrade().unwrap(); + let reader = reader.lock().unwrap(); + assert_eq!(reader.copies.len(), 1, "the first copy was let go"); + assert!(reader.copies[0].floor.is_some(), "the serving copy has a failover cap"); + } + + let subscription = Subscription::default() + .with_max_delay(Duration::from_secs(10)) + .with_ranges([ + track::Range::groups(0..1), + track::Range { + start: Anchor::Relative(1), + end: None, + }, + ]); + sub.update(subscription).unwrap(); + drain(&mut sub, false); + assert_eq!( + sub.next_in_range(1), + Some(4), + "the relative range resolved on the serving copy" + ); + assert_eq!(sub.floor(), Some(0)); + } + + /// A resumed reader names the next group its ranges cover, so a reader waiting on each + /// group in sequence walks over the gap between two ranges instead of waiting for it. + #[test] + #[cfg(feature = "async")] + fn a_resumed_reader_names_the_next_group_in_range() { + let routes = Producer::new(); + let logical = logical(&routes); + let a = copy(); + routes.serve(a.consume()); + let subscription = Subscription::default() + .with_max_delay(Duration::from_secs(10)) + .with_ranges([track::Range::groups(0..1), track::Range::groups(10..11)]); + let mut sub = logical + .consume() + .subscribe(subscription) + .now_or_never() + .unwrap() + .unwrap(); + for sequence in [0, 5, 10] { + write(&a, sequence); + } + let mut read = drain(&mut sub, false); + read.sort(); + assert_eq!(read, [0, 10]); + assert_eq!( + sub.next_in_range(1), + Some(10), + "the gap between the ranges is never read" + ); + assert_eq!(sub.next_in_range(11), None); + } + #[test] #[cfg(feature = "async")] fn datagram_readers_release_replaced_subscriptions() { diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index e8c37729e8e..81f53a38461 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -3,6 +3,9 @@ use std::{ time::Duration, }; +mod window; +pub(super) use window::{Resolved, Window}; + /// Subscriber-side preferences for receiving a track. /// /// Each subscriber holds its own [`Subscription`]; the publisher observes an @@ -70,11 +73,10 @@ pub struct Subscription { /// for nothing. /// /// A request to the publisher, aggregated across every live subscriber as the union of - /// their ranges, so it says what the publisher sends, not what any one subscriber sees. - /// The one place it touches this subscriber's reads is where its cursor starts: at the - /// lowest group any of its ranges names. [`crate::track::Subscriber::set_groups`] is the - /// local read filter. See [Local cursor vs wire - /// preference](crate::track::Subscriber#local-cursor-vs-wire-preference). + /// their ranges, and this subscriber's read filter: it reads only the groups its own + /// ranges cover, whatever another subscriber widens the union to. + /// [`crate::track::Subscriber::set_groups`] narrows the reads further. See [Local cursor + /// vs wire preference](crate::track::Subscriber#local-cursor-vs-wire-preference). pub ranges: Vec, /// Which pending group the publisher sends first; see [`Order`]. /// @@ -102,13 +104,13 @@ pub enum Order { /// Where one end of a [`Range`] falls: a fixed position, or a count of groups back from the /// latest one. /// -/// A relative anchor resolves against the latest group of the hop that serves it, once, -/// when it starts serving. Before any group exists it resolves at the first one, so -/// [`Range::live`] starts there. +/// A relative anchor resolves against the latest group of the hop that serves it, when it +/// starts serving and again whenever the subscriber replaces its ranges. Before any group +/// exists it resolves at the first one, so [`Range::live`] starts there. /// /// No published wire carries one yet: upstream, a relative start is no floor, the same -/// request as [`Range::oldest`], and a relative end is no end. Behind a moq-transport upstream, -/// which joins without a floor at the live group, `Relative(n)..` starts there. +/// request as [`Range::oldest`], and a relative end is no end. Behind a moq-transport +/// upstream, which joins without a floor at the live group, `Relative(n)..` starts there. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Anchor { /// This exact position. @@ -167,16 +169,6 @@ impl Range { } } - /// The first position this range delivers, or `None` while it waits on a latest group. - fn resolve_start(&self, latest: Option) -> Option { - match self.start { - Anchor::Absolute(start) => Some(start), - // Nothing sits below group 0, so the farthest offset needs no latest group. - Anchor::Relative(u64::MAX) => Some(Position::default()), - Anchor::Relative(back) => Some(Position::group(latest?.saturating_sub(back))), - } - } - /// Whether this range certainly delivers nothing, wherever the latest group lands. pub(super) fn is_empty(&self) -> bool { match (self.start, self.end) { @@ -352,44 +344,49 @@ impl Subscription { } /// These preferences with every range raised to start no earlier than `floor` and - /// stop no later than `end`, the exclusive end. + /// stop no later than `end`, the exclusive end, without ever widening one. /// - /// A relative anchor becomes the bound itself, since which of the two is tighter - /// depends on a latest group this hop may not know. That asks for no less than the - /// range would. - pub(super) fn clamped(&self, floor: Option, end: Option) -> Self { + /// `resolved` names where each range resolved, by index, so a relative anchor + /// becomes its own group, raised to `floor` or capped at `end`. A start not resolved + /// keeps its anchor, since the floor may lie before it, so the caller drops what it + /// resolves to below `floor`; an end not resolved becomes the bound. + pub(super) fn clamped( + &self, + floor: Option, + end: Option, + resolved: &[Option], + ) -> Self { + let clamping = floor.is_some() || end.is_some(); let ranges = self .ranges .iter() - .map(|range| Range { - start: match (range.start, floor) { - (start, None) => start, - (Anchor::Absolute(start), Some(floor)) => Anchor::Absolute(start.max(floor)), - (Anchor::Relative(_), Some(floor)) => Anchor::Absolute(floor), - }, - end: match (range.end, end) { - (requested, None) => requested, - (Some(Anchor::Absolute(requested)), Some(end)) => Some(Anchor::Absolute(requested.min(end))), - (_, Some(end)) => Some(Anchor::Absolute(end)), - }, + .enumerate() + .map(|(index, range)| { + let resolved = resolved.get(index).copied().flatten().filter(|_| clamping); + let start = match (range.start, resolved) { + (Anchor::Relative(_), Some(resolved)) => Anchor::Absolute(Position::group(resolved.start)), + (start, _) => start, + }; + let requested = match (range.end, resolved.and_then(|resolved| resolved.end)) { + (Some(Anchor::Relative(_)), Some(resolved)) => Some(Anchor::Absolute(Position::group(resolved))), + (requested, _) => requested, + }; + Range { + start: match (start, floor) { + (Anchor::Absolute(start), Some(floor)) => Anchor::Absolute(start.max(floor)), + (start, _) => start, + }, + end: match (requested, end) { + (requested, None) => requested, + (Some(Anchor::Absolute(requested)), Some(end)) => Some(Anchor::Absolute(requested.min(end))), + (_, Some(end)) => Some(Anchor::Absolute(end)), + }, + } }) .collect(); Self { ranges, ..self.clone() } } - /// The lowest group this subscription's ranges start at, resolving relative starts - /// against `latest`. `None` while one waits on a latest group that does not exist yet. - /// - /// Empty ranges ask for nothing, and no ranges at all floor nowhere: group 0. - pub(super) fn floor(&self, latest: Option) -> Option { - let mut floor = None; - for range in self.ranges.iter().filter(|range| !range.is_empty()) { - let start = range.resolve_start(latest)?.group; - floor = Some(floor.map_or(start, |floor: u64| floor.min(start))); - } - Some(floor.unwrap_or(0)) - } - /// Whether a subscriber joins at the live edge: a range counting back from the latest /// group and following it. A fixed start resumes a known group instead, such as a route /// switch picking up where the reader stopped. @@ -632,26 +629,28 @@ mod tests { /// group exists only the farthest offset resolves, since it is group 0 regardless. #[test] fn relative_starts_resolve_against_the_latest_group() { + let floor = |sub: &Subscription, latest| Window::new(&sub.ranges, latest).floor(); + let live = Subscription::default().with_ranges([Range::live()]); - assert_eq!(live.floor(Some(7)), Some(7)); - assert_eq!(live.floor(None), None); + assert_eq!(floor(&live, Some(7)), Some(7)); + assert_eq!(floor(&live, None), None); let buffering = Subscription::default().with_ranges([range(Anchor::Relative(3), None)]); - assert_eq!(buffering.floor(Some(7)), Some(4)); - assert_eq!(buffering.floor(Some(1)), Some(0), "an offset past the start clamps"); + assert_eq!(floor(&buffering, Some(7)), Some(4)); + assert_eq!(floor(&buffering, Some(1)), Some(0), "an offset past the start clamps"); - assert_eq!(Subscription::default().floor(None), Some(0)); + assert_eq!(floor(&Subscription::default(), None), Some(0)); // The lowest range wins, and one unresolved range leaves the whole floor unresolved. let mixed = Subscription::default().with_ranges([range(absolute(4, 0), None), Range::live()]); - assert_eq!(mixed.floor(Some(3)), Some(3)); - assert_eq!(mixed.floor(Some(9)), Some(4)); - assert_eq!(mixed.floor(None), None); + assert_eq!(floor(&mixed, Some(3)), Some(3)); + assert_eq!(floor(&mixed, Some(9)), Some(4)); + assert_eq!(floor(&mixed, None), None); // Nothing asked for floors nowhere, and an empty range asks for nothing. - assert_eq!(Subscription::default().with_ranges([]).floor(None), Some(0)); + assert_eq!(floor(&Subscription::default().with_ranges([]), None), Some(0)); let empty = Subscription::default().with_ranges([Range::groups(5..5), Range::groups(10..)]); - assert_eq!(empty.floor(None), Some(10)); + assert_eq!(floor(&empty, None), Some(10)); } /// The bounds a wire without ranges carries: the lowest absolute start and the highest diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs new file mode 100644 index 00000000000..8fd46645bf8 --- /dev/null +++ b/rs/moq-net/src/model/subscription/window.rs @@ -0,0 +1,287 @@ +//! A subscriber's own ranges, resolved to the group sequences it reads. +//! +//! The aggregate a publisher sees is the union of every subscriber's ranges, so it says +//! what arrives, not what one subscriber asked for. A [`Window`] is the per-subscriber +//! side: resolved at the hop serving the subscriber, it filters that subscriber's reads. + +use std::ops; + +use rangemap::RangeInclusiveSet; + +use super::{Anchor, Range}; + +/// One range resolved to groups: from `start` up to the exclusive `end`, `None` while it +/// stays open. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::model) struct Resolved { + pub start: u64, + pub end: Option, +} + +/// A subscriber's [`Range`]s resolved to the group sequences it reads. +#[derive(Debug)] +pub(in crate::model) struct Window { + /// What these were resolved from, so only replacing the ranges resolves again. + ranges: Vec, + spans: Vec, + /// The groups the spans cover. + groups: RangeInclusiveSet, + /// A relative anchor waits on a first group, so nothing is filtered yet. + pending: bool, +} + +/// One non-empty range, resolved. +#[derive(Clone, Copy, Debug)] +struct Span { + /// Which of the ranges it came from. + index: usize, + start: u64, + end: End, + /// Whether the start names a group, rather than reaching back to the oldest one. + floored: bool, +} + +#[derive(Clone, Copy, Debug)] +enum End { + /// Before this group. + Before(u64), + /// Past every group. + Open, +} + +impl Window { + /// Resolve `ranges`, counting relative anchors back from `latest`. + pub fn new(ranges: &[Range], latest: Option) -> Self { + let mut window = Self { + ranges: ranges.to_vec(), + spans: Vec::new(), + groups: RangeInclusiveSet::new(), + pending: false, + }; + window.place(latest); + window + } + + /// Whether a relative anchor still waits on a first group. + pub fn is_pending(&self) -> bool { + self.pending + } + + /// Resolve the relative anchors waiting on a first group once `latest` exists. + /// Returns whether anything moved. + pub fn settle(&mut self, latest: Option) -> bool { + if !self.pending || latest.is_none() { + return false; + } + self.place(latest); + !self.pending + } + + /// Whether group `sequence` is in range. Everything is while a relative anchor waits. + pub fn contains(&self, sequence: u64) -> bool { + self.pending || self.groups.contains(&sequence) + } + + /// The lowest group in range, `None` while a relative anchor waits. Nothing in range + /// floors nowhere: group 0. + pub fn floor(&self) -> Option { + match self.pending { + true => None, + false => Some(self.groups.iter().next().map_or(0, |run| *run.start())), + } + } + + /// The lowest group the ranges name, `None` while one reaches back to the oldest group + /// or a relative anchor waits on a first group. + pub fn start(&self) -> Option { + if self.pending || self.spans.iter().any(|span| !span.floored) { + return None; + } + self.spans.iter().map(|span| span.start).min() + } + + /// Each range resolved, in the order given: `None` for one that is empty or still + /// waits on a first group. + pub fn resolved(&self) -> Vec> { + let mut resolved = vec![None; self.ranges.len()]; + for span in &self.spans { + resolved[span.index] = Some(Resolved { + start: span.start, + end: match span.end { + End::Before(end) => Some(end), + End::Open => None, + }, + }); + } + resolved + } + + /// The exclusive end past every group in range, `None` when one stays open. + pub fn end(&self) -> Option { + if self.pending { + return None; + } + match self.groups.iter().next_back() { + Some(run) => run.end().checked_add(1), + None => Some(0), + } + } + + /// The groups in range from `from` on, lowest first. + pub fn runs(&self, from: u64) -> impl Iterator> + '_ { + let all = self.pending.then_some(from..=u64::MAX); + let runs = (!self.pending).then(|| { + self.groups + .overlapping(from..=u64::MAX) + .map(move |run| (*run.start()).max(from)..=*run.end()) + }); + all.into_iter().chain(runs.into_iter().flatten()) + } + + /// 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)?; + if self.pending { + return Some(range); + } + let run = self.groups.overlapping(range.start..=last).next()?; + let end = match run.end().checked_add(1) { + Some(end) => end.min(range.end), + None => range.end, + }; + Some((*run.start()).max(range.start)..end) + } + + fn place(&mut self, latest: Option) { + self.spans.clear(); + self.pending = false; + for (index, range) in self.ranges.iter().enumerate().filter(|(_, range)| !range.is_empty()) { + let (Some((start, floored)), Some(end)) = (start(range.start, latest), end(range.end, latest)) else { + self.pending = true; + continue; + }; + self.spans.push(Span { + index, + start, + end, + floored, + }); + } + self.groups = RangeInclusiveSet::new(); + for span in &self.spans { + let last = match span.end { + End::Before(end) => match end.checked_sub(1) { + Some(last) if last >= span.start => last, + _ => continue, + }, + End::Open => u64::MAX, + }; + self.groups.insert(span.start..=last); + } + } +} + +/// The first group a range starts at, and whether it names that group rather than +/// reaching back to the oldest one. `None` while it waits on a latest group. +fn start(anchor: Anchor, latest: Option) -> Option<(u64, bool)> { + match anchor { + Anchor::Absolute(start) => Some((start.group, true)), + // Nothing sits below group 0, so the farthest offset needs no latest group. + Anchor::Relative(u64::MAX) => Some((0, false)), + Anchor::Relative(back) => Some((latest?.saturating_sub(back), true)), + } +} + +/// Where a range stops, or `None` while it waits on a latest group. +fn end(anchor: Option, latest: Option) -> Option { + Some(match anchor { + None => End::Open, + Some(Anchor::Absolute(end)) => end.final_sequence().map_or(End::Open, End::Before), + Some(Anchor::Relative(back)) => latest? + .saturating_sub(back) + .checked_add(1) + .map_or(End::Open, End::Before), + }) +} + +#[cfg(test)] +mod tests { + use super::super::Position; + use super::*; + + fn range(start: Anchor, end: impl Into>) -> Range { + Range { start, end: end.into() } + } + + fn covered(window: &Window, upto: u64) -> Vec { + (0..upto).filter(|sequence| window.contains(*sequence)).collect() + } + + /// Disjoint ranges filter to exactly their groups; the gap between them is out. + #[test] + fn ranges_cover_only_their_groups() { + let window = Window::new(&[Range::groups(2..4), Range::groups(7..)], Some(9)); + assert_eq!(covered(&window, 10), vec![2, 3, 7, 8, 9]); + assert_eq!(window.floor(), Some(2)); + assert_eq!(window.end(), None); + assert_eq!(window.runs(3).collect::>(), vec![3..=3, 7..=u64::MAX]); + assert_eq!(window.clip(0..10), Some(2..4)); + assert_eq!(window.clip(4..10), Some(7..10)); + assert_eq!(window.clip(4..7), None); + assert_eq!( + window.resolved(), + vec![ + Some(Resolved { start: 2, end: Some(4) }), + Some(Resolved { start: 7, end: None }), + ] + ); + + // A mid-group end includes its group, a head-of-group end excludes it. + let window = Window::new( + &[range( + Anchor::Absolute(Position { group: 1, frame: 2 }), + Anchor::Absolute(Position { group: 3, frame: 1 }), + )], + None, + ); + assert_eq!(covered(&window, 6), vec![1, 2, 3]); + assert_eq!(window.end(), Some(4)); + + // Nothing asked for is nothing in range, floored nowhere. + let window = Window::new(&[], None); + assert_eq!(covered(&window, 4), Vec::::new()); + assert_eq!((window.floor(), window.end()), (Some(0), Some(0))); + } + + /// Relative anchors count back from the latest group, both ends whole groups, and wait + /// for a first group when there is none. + #[test] + fn relative_anchors_count_back_from_the_latest_group() { + let window = Window::new(&[range(Anchor::Relative(3), Anchor::Relative(1))], Some(7)); + assert_eq!(covered(&window, 10), vec![4, 5, 6]); + + let mut window = Window::new(&[Range::live()], None); + assert!(window.is_pending() && window.contains(3) && window.floor().is_none()); + assert!(!window.settle(None)); + assert!(window.settle(Some(5))); + assert_eq!(covered(&window, 8), vec![5, 6, 7]); + assert!(!window.is_pending()); + + // The farthest offset needs no latest group, and names no floor. + let window = Window::new(&[Range::oldest()], None); + assert_eq!((window.floor(), window.start()), (Some(0), None)); + } + + /// The named floor is the lowest start, unless a range reaches back to the oldest + /// group or still waits on a first one; an explicit group 0 names one. + #[test] + fn the_named_start_is_the_lowest_unless_one_reaches_back() { + let start = |ranges: &[Range], latest| Window::new(ranges, latest).start(); + assert_eq!(start(&[Range::groups(4..), Range::live()], Some(9)), Some(4)); + assert_eq!(start(&[Range::groups(4..), Range::live()], Some(2)), Some(2)); + assert_eq!(start(&[Range::groups(0..)], None), Some(0)); + assert_eq!(start(&[Range::groups(4..), Range::live()], None), None); + assert_eq!(start(&[Range::groups(4..), Range::oldest()], Some(9)), None); + assert_eq!(start(&[Range::groups(4..), Range::groups(..2)], Some(9)), None); + } +} diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index e0eee93a9db..24d9ea02c76 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -22,7 +22,8 @@ use super::{Datagram, Requests}; use super::Cap; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; -use rangemap::RangeSet; +use super::subscription::{Resolved, Window}; +use rangemap::{RangeInclusiveSet, RangeSet}; mod demand; pub(super) use demand::Preferences; @@ -525,6 +526,12 @@ impl Slot { self.is_live() && !self.pending } + /// Whether the arrival walk hands this slot out: the live feed's, not withheld, and + /// still serving, a fetched head counting for an aborted live group. + fn is_walked(&self) -> bool { + self.visible && !self.pending && !self.is_aborted() + } + /// The copy to hand readers: the fetched head while it lasts, else the live group. fn serving(&self) -> &group::Producer { match &self.head { @@ -858,7 +865,7 @@ impl TrackState { self.shown(sequence); } - /// Find the smallest-sequence cached group satisfying + /// Find the smallest-sequence cached group in `window` satisfying /// `next_sequence <= seq < end_sequence (if set)`. Used by /// [`Subscriber::next_group`] so the range can be widened (or unset) /// after the fact and previously-skipped cached groups become available @@ -871,6 +878,7 @@ impl TrackState { &self, next_sequence: u64, end_sequence: Option, + window: &Window, ) -> Poll>> { // Nothing more can arrive once the track was sealed (dropped with its end // declared) or aborted. Either closed the channel, so parking is not an option: @@ -884,10 +892,14 @@ impl TrackState { }; // If the exclusive end is already at or below where we'd resume, no - // group can ever satisfy this call until the cap rises. Pending (not - // None) so the consumer is parked rather than told the stream is over. - // An empty range (`end == 0`) parks even at the first sequence. - if let Some(cap) = end_sequence + // group can ever satisfy this call until the cap rises or the ranges widen. + // Pending (not None) so the consumer is parked rather than told the stream + // is over. An empty range (`end == 0`) parks even at the first sequence. + let cap = match (end_sequence, window.end()) { + (Some(cap), Some(end)) => Some(cap.min(end)), + (cap, end) => cap.or(end), + }; + if let Some(cap) = cap && cap <= next_sequence { if closed { @@ -896,13 +908,16 @@ impl TrackState { return Poll::Pending; } - let best = self - .lookup - .range(next_sequence.max(self.live_floor.unwrap_or(0))..) + let best = window + .runs(next_sequence.max(self.live_floor.unwrap_or(0))) .filter(|_| self.readable()) - .map(|(_, slot)| slot.serving()) - .take_while(|group| super::subscription::before_end(group.sequence, end_sequence)) - .find(|group| !group.is_aborted()); + .find_map(|run| { + self.lookup + .range(run) + .map(|(_, slot)| slot.serving()) + .take_while(|group| super::subscription::before_end(group.sequence, end_sequence)) + .find(|group| !group.is_aborted()) + }); if let Some(group) = best { // Deliberately no cache refresh here: this is a pure seek, and the caller may @@ -950,8 +965,8 @@ impl TrackState { } } - /// The live edge a subscription bounded at `cap` measures drift against: the - /// highest-sequence group that has presented a frame, or `None` while nothing + /// The live edge a subscription served `servable` measures drift against: the + /// highest-sequence group in it that has presented a frame, or `None` while nothing /// stamped is servable (an unstamped track drives no media-time expiry; the wall /// clock alone judges it). /// @@ -959,39 +974,73 @@ impl TrackState { /// every candidate below it and above none at or past it. Recomputing per candidate /// would make walking a backlog of N groups off in one poll cost O(N^2). /// - /// Capped because a group can only be late relative to data that would actually be + /// Bounded because a group can only be late relative to data that would actually be /// served in its place: a reader capped by [`Subscriber::set_groups`] can't jump past - /// the cap, so the groups it still wants aren't stale just because the track ran on. + /// the cap, and one reading disjoint ranges never reads the gap between them, so the + /// groups it still wants aren't stale just because the track ran on. /// Fetched backfill is absent from `arrival`, so it cannot age subscription content /// as though it were a live replacement. - fn live_edge(&self, cap: Option) -> Option { - self.lookup - .range((Bound::Unbounded, cap.map_or(Bound::Unbounded, Bound::Excluded))) - .rev() - .find_map(|(_, slot)| { - if !slot.is_shown() { - return None; - } - // The map is ordered by sequence, so the first stamped group from the - // back is the newest content that exists. - let timestamp = slot.group.timestamp()?; - Some(PresentationEdge { - sequence: slot.group.sequence, - stamp: slot.stamp, - timestamp: slot.group.latest().unwrap_or(timestamp), - }) + fn live_edge(&self, servable: &Servable) -> Option { + self.find_servable(servable, 0, true, |slot| { + if !slot.is_shown() { + return None; + } + // The map is ordered by sequence, so the first stamped group from the + // back is the newest content that exists. + let timestamp = slot.group.timestamp()?; + Some(PresentationEdge { + sequence: slot.group.sequence, + stamp: slot.stamp, + timestamp: slot.group.latest().unwrap_or(timestamp), }) + }) } - /// This track's own edge under the exclusive `cap`, for measuring drift. + /// This track's own edge within `servable`, for measuring drift. /// /// None on an untimed track, where nothing is ever stale: its groups carry no /// timestamp, so looking for one would scan the whole cache on every poll. - fn drift_edge(&self, cap: Option) -> Edge { + fn drift_edge(&self, servable: &Servable) -> Edge { let untimed = self.info.as_ref().is_some_and(|info| info.timescale.is_none()); Edge { - presentation: if untimed { None } else { self.live_edge(cap) }, - cap, + presentation: if untimed { None } else { self.live_edge(servable) }, + servable: servable.clone(), + } + } + + /// 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. + fn find_servable<'a, R>( + &'a self, + servable: &Servable, + from: u64, + rev: bool, + mut f: impl FnMut(&'a Slot) -> Option, + ) -> Option { + let mut scan = |range: std::collections::btree_map::Range<'a, u64, Slot>| match rev { + true => range.rev().find_map(|(_, slot)| f(slot)), + false => range.map(|(_, slot)| slot).find_map(&mut f), + }; + match servable { + Servable::Run { start, end } => { + let from = from.max(*start); + // A range ending before it starts would panic, and holds nothing anyway. + if end.is_some_and(|end| end < from) { + return None; + } + let end = end.map_or(Bound::Unbounded, Bound::Excluded); + 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), + } + } } } @@ -1009,31 +1058,27 @@ impl TrackState { /// own last timestamp says where it *starts* presenting, not where it ends; only its /// successor's start proves it cannot run past it. The bound is the *immediate* /// servable successor's start, so an unstamped successor leaves the reach unbounded - /// until it presents its first frame, and so does having no servable successor below - /// `cap` at all. - fn reach(&self, sequence: u64, cap: Option) -> Option { - self.successor(sequence, cap)?.group.timestamp() + /// until it presents its first frame, and so does having no successor in `servable` + /// at all. + fn reach(&self, sequence: u64, servable: &Servable) -> Option { + self.successor(sequence, servable)?.group.timestamp() } - /// The group after `sequence` that bounds it: the first servable one below `cap`, + /// The group after `sequence` that bounds it: the first servable one in `servable`, /// stamped or not, empty or not. - fn successor(&self, sequence: u64, cap: Option) -> Option<&Slot> { - self.lookup - .range(sequence.saturating_add(1)..) - .map(|(_, slot)| slot) - .take_while(|slot| super::subscription::before_end(slot.group.sequence, cap)) - .find(|slot| slot.is_shown()) + fn successor(&self, sequence: u64, servable: &Servable) -> Option<&Slot> { + self.find_servable(servable, sequence.checked_add(1)?, false, |slot| { + slot.is_shown().then_some(slot) + }) } /// The group after `sequence` that starts its wall clock: like [`Self::successor`], but /// counting a group a route is still withholding. Its header arrived, so a relay ages the /// group before it as the origin does, even if its first frame never comes. - fn arrival(&self, sequence: u64, cap: Option) -> Option<&Slot> { - self.lookup - .range(sequence.saturating_add(1)..) - .map(|(_, slot)| slot) - .take_while(|slot| super::subscription::before_end(slot.group.sequence, cap)) - .find(|slot| slot.is_live()) + fn arrival(&self, sequence: u64, servable: &Servable) -> Option<&Slot> { + self.find_servable(servable, sequence.checked_add(1)?, false, |slot| { + slot.is_live().then_some(slot) + }) } /// The slot `select` picks, with `waiter` parked on its abort. @@ -1059,8 +1104,8 @@ impl TrackState { /// a group that lands between the two restarts the clock, and an aborted successor /// hands it to the next one. The clock is read only once there is a successor, /// so judging the newest group, the common case, costs no clock read. - fn superseded(&self, sequence: u64, cap: Option, budget: Duration) -> bool { - self.arrival(sequence, cap) + fn superseded(&self, sequence: u64, servable: &Servable, budget: Duration) -> bool { + self.arrival(sequence, servable) .is_some_and(|successor| self.aged(successor, budget)) } @@ -1105,11 +1150,11 @@ impl TrackState { /// [`Self::is_stale`] for group `sequence`, whether this track holds it or not: its /// successor here starts a full `budget` behind the edge, or arrived a full `budget` ago. fn drifted(&self, sequence: u64, edge: &Edge, budget: Duration) -> bool { - if self.superseded(sequence, edge.cap, budget) { + if self.superseded(sequence, &edge.servable, budget) { return true; } self.live_above(sequence, edge) - .is_some_and(|live| live.behind(self.reach(sequence, edge.cap), budget)) + .is_some_and(|live| live.behind(self.reach(sequence, &edge.servable), budget)) } /// The edge that could convict group `sequence`: above it, and still holding the @@ -1120,18 +1165,18 @@ impl TrackState { .filter(|live| live.sequence > sequence && self.holds(live.sequence, live.stamp)) } - /// [`Self::drifted`] under `cap`, parking `waiter` on exactly what else can move the + /// [`Self::drifted`] within `servable`, parking `waiter` on exactly what else can move the /// verdict: the successor's first frame or abort, the edge's abort, the edge crossing /// the media deadline, and the wall clock crossing the successor's arrival plus /// `budget`. Parking on every track change instead wakes each of N parked reads per append. /// /// The caller parks on a group landing above `sequence` first, in the way that fits /// whether this track holds it. - fn poll_drifted(&self, sequence: u64, cap: Option, budget: Duration, waiter: &kio::Waiter) -> bool { + fn poll_drifted(&self, sequence: u64, servable: &Servable, budget: Duration, waiter: &kio::Waiter) -> bool { // Unbounded until a group lands above. Registered before judging, so a racing abort // is observed or wakes us, and armed for whichever successor this pass selected, so // re-selecting re-arms it. - let Some(arrival) = self.watch_abort(waiter, |state| state.arrival(sequence, cap)) else { + let Some(arrival) = self.watch_abort(waiter, |state| state.arrival(sequence, servable)) else { return false; }; if let Some(deadline) = arrival.arrived.checked_add(budget) { @@ -1143,7 +1188,7 @@ impl TrackState { let reach = loop { // An abort can change reach even after the successor is stamped. Unbounded // until a group shows above, or the successor presents its first frame. - let Some(successor) = self.watch_abort(waiter, |state| state.successor(sequence, cap)) else { + let Some(successor) = self.watch_abort(waiter, |state| state.successor(sequence, servable)) else { return false; }; let successor = &successor.group; @@ -1158,7 +1203,7 @@ impl TrackState { // either seen here or wakes us. self.cache.wakes().watch_deadline(reach, budget, waiter); loop { - let edge = self.drift_edge(cap); + let edge = self.drift_edge(servable); // The edge's abort presents nothing and never touches the track, yet hands the // edge to a lower group that may already sit past the deadline. Watch it, and // re-resolve if it already landed. Terminates: an aborted group is never the @@ -2849,10 +2894,10 @@ impl Producer { /// The info is fixed at creation, so there's nothing to wait for (no /// SUBSCRIBE_OK round trip). Pass `None` for [`Subscription::default`]. /// - /// The read cursor starts at the group the subscription named (its floor), or 0. - /// [`Subscription::max_delay`] is what asks for data: delivery skips everything above - /// the floor that the budget convicts, so the default budget of zero delivers only - /// the latest group and a larger one reaches back over what it can still use. + /// Reads only the groups the subscription's ranges cover. + /// [`Subscription::max_delay`] is what asks for data: delivery skips everything in range + /// that the budget convicts, so the default budget of zero delivers only the latest + /// group and a larger one reaches back over what it can still use. pub fn subscribe(&self, subscription: impl Into>) -> Subscriber { let preferences = subscription.into().unwrap_or_default(); @@ -3665,10 +3710,10 @@ impl Consumer { /// [`Subscriber`] once the track info is available, or the track's abort error (or /// [`Error::Dropped`]) if it is already closed. /// - /// The read cursor starts at the group the subscription named (its floor), or 0. - /// [`Subscription::max_delay`] is what asks for data: delivery skips everything above - /// the floor that the budget convicts, so the default budget of zero delivers only - /// the latest group and a larger one reaches back over what it can still use. + /// Reads only the groups the subscription's ranges cover. + /// [`Subscription::max_delay`] is what asks for data: delivery skips everything in range + /// that the budget convicts, so the default budget of zero delivers only the latest + /// group and a larger one reaches back over what it can still use. pub fn subscribe(&self, subscription: impl Into>) -> kio::Pending { let (state, subscription) = self.resolve(Some(&subscription.into().unwrap_or_default())); let subscription = subscription.expect("a subscription was requested"); @@ -3831,7 +3876,7 @@ impl Consumer { let state = state.read(); let budget = clamp_max_delay(budget, state.max_age_bound()); state.cache.wakes().watch_held(sequence, waiter); - match state.poll_drifted(sequence, None, budget, waiter) { + match state.poll_drifted(sequence, &Servable::ALL, budget, waiter) { true => Poll::Ready(()), false => Poll::Pending, } @@ -4431,7 +4476,7 @@ impl kio::Task for Fetching { let state = state.read(); let budget = clamp_max_delay(self.max_delay.unwrap_or(Duration::MAX), state.max_age_bound()); state.cache.wakes().watch_landing(sequence, waiter); - if state.poll_drifted(sequence, None, budget, waiter) { + if state.poll_drifted(sequence, &Servable::ALL, budget, waiter) { return Poll::Ready(Err(Error::Old)); } } @@ -4525,25 +4570,19 @@ impl kio::Task for Fetching { /// /// # Local cursor vs wire preference /// -/// Group bounds exist at two levels, and setting one does not imply the other: +/// Group bounds exist at two levels: /// -/// - [`Self::set_groups`] limits **this subscriber's reads**, filtering exactly what -/// this handle returns without changing the publisher's demand. -/// - [`Subscription::ranges`], set via [`Self::update`], are a **request to the -/// publisher**. They're aggregated across every live subscriber (their union), so they -/// say what the publisher should send, not what this subscriber sees. +/// - [`Subscription::ranges`], set via [`Self::update`], are both a **request to the +/// publisher** and **this subscriber's read filter**. The publisher sees the union across +/// every live subscriber, but each subscriber reads only the groups its own ranges cover, +/// resolved where it is served: a relative anchor counted back from the latest group (or +/// the first to arrive, before any exists), again whenever the ranges are replaced. +/// - [`Self::set_groups`] narrows **this subscriber's reads** further, without changing the +/// publisher's demand. /// -/// They stay separate because their scopes differ: a subscriber can't filter by the -/// aggregate, since another subscriber can widen it, and the publisher can't honor a -/// cursor it's never told about. So setting only the cursor still transfers the skipped -/// groups, and setting only the preference still returns groups another subscriber asked -/// for. Set both to skip them *and* avoid the transfer. -/// -/// The one place they meet is where the cursor comes from. A new subscriber's cursor is -/// floored at the lowest group its own ranges start at, a relative start counted back from -/// the latest group (or the first to arrive, before any exists), and its -/// [`Subscription::max_delay`] decides what above the floor is worth delivering. Every later -/// move is the caller's. +/// A group outside either is held rather than skipped past, so widening either one later +/// makes it available again. Within both, [`Subscription::max_delay`] decides what is +/// still worth delivering, measured against the track's live edge. pub struct Subscriber { name: Arc, // The broadcast this track belongs to; see [`Self::broadcast`]. @@ -4602,7 +4641,7 @@ enum Inner { /// One poll's view of how far this subscription may drift: the clamped budget and the /// live edge to measure a candidate group against. Resolved once, then applied to every /// group that poll considers. -#[derive(Clone, Copy)] +#[derive(Clone)] struct Drift { budget: Duration, edge: Edge, @@ -4617,8 +4656,8 @@ struct GroupExpiry { /// the last real subscriber. state: kio::ConsumerWeak, subscription: kio::Consumer, - /// The cursor's drift cap; see [`Cursor::drift_cap`]. - cap: kio::Consumer>, + /// What the cursor can be served; see [`Cursor::published`]. + servable: kio::Consumer, sequence: u64, } @@ -4650,7 +4689,7 @@ impl group::Expiry for FetchExpiry { }; let budget = clamp_max_delay(budget, state.max_age_bound()); state.cache.wakes().watch_landing(self.sequence, waiter); - state.poll_drifted(self.sequence, None, budget, waiter) && state.lookup.contains_key(&self.sequence) + state.poll_drifted(self.sequence, &Servable::ALL, budget, waiter) && state.lookup.contains_key(&self.sequence) } } @@ -4661,7 +4700,7 @@ impl group::Expiry for GroupExpiry { Arc::new(Self { state: state.unwrap_or_else(|| self.state.clone()), subscription: self.subscription.clone(), - cap: self.cap.clone(), + servable: self.servable.clone(), sequence: self.sequence, }) } @@ -4676,28 +4715,109 @@ impl group::Expiry for GroupExpiry { max_delay }); - let mut cap = None; - let _ = self.cap.poll(waiter, |current| { - cap = **current; + let mut servable = Servable::ALL; + let _ = self.servable.poll(waiter, |current| { + servable = current.servable.clone(); Poll::<()>::Pending }); let state = self.state.read(); let budget = clamp_max_delay(max_delay, state.max_age_bound()); state.cache.wakes().watch_landing(self.sequence, waiter); - state.poll_drifted(self.sequence, cap, budget, waiter) && state.lookup.contains_key(&self.sequence) + state.poll_drifted(self.sequence, &servable, budget, waiter) && state.lookup.contains_key(&self.sequence) } } /// The group a poll's drift is measured against, identified well enough to tell it apart /// from whatever may occupy its sequence by the time a candidate is judged. -#[derive(Clone, Copy)] +#[derive(Clone)] struct Edge { /// This track's own edge, revalidated before it convicts anything. presentation: Option, - /// The cap the edge was resolved under, so per-candidate reach lookups measure + /// What the edge was resolved within, so per-candidate reach lookups measure /// against the same servable window. + servable: Servable, +} + +/// The groups `window` covers from `from`, below the exclusive `cap`. +fn admitted(window: &Window, from: u64, cap: Option) -> RangeInclusiveSet { + below(window.runs(from), cap) +} + +/// `runs`, lowest first, below the exclusive `cap`. +fn below(runs: impl Iterator>, cap: Option) -> RangeInclusiveSet { + let mut below = RangeInclusiveSet::new(); + let last = match cap { + Some(end) => match end.checked_sub(1) { + Some(last) => last, + None => return below, + }, + None => u64::MAX, + }; + for run in runs { + if *run.start() > last { + break; + } + below.insert(*run.start()..=(*run.end()).min(last)); + } + below +} + +/// A cursor's [`Servable`] as the groups it handed out see it, derived from its two +/// inputs: the window, which a [`Control`] replacing the ranges republishes, and the cap, +/// which only the cursor moves. Each writer replaces its own input and derives the servable +/// under the same write, so neither can publish one cut from the other's stale input. +#[derive(Clone, PartialEq)] +struct Published { + /// The window's groups, resolved for `revision` of the ranges. + runs: Arc>, + /// The [`Preferences::ranges_revision`] `runs` were resolved for. + revision: u64, cap: Option, + /// `runs` below `cap`. + servable: Servable, +} + +impl Published { + fn new(runs: Arc>, revision: u64, cap: Option) -> Self { + let servable = Servable::new(below(runs.iter().cloned(), cap)); + Self { + runs, + revision, + cap, + servable, + } + } +} + +/// 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)] +enum Servable { + /// From `start` up to the exclusive `end`, or onward without one: one run, the common + /// case, kept as cheap to copy and look up as a cap. Its start still bounds a group + /// handed out before the run moved above it, which is judged against the run. + Run { start: u64, end: Option }, + /// Disjoint runs, lowest first. + Runs(Arc>), +} + +impl Servable { + /// Every group, for a reader no window narrows. + const ALL: Self = Self::Run { start: 0, end: None }; + + /// The groups in `runs`. + fn new(runs: RangeInclusiveSet) -> Self { + let mut iter = runs.iter(); + match (iter.next(), iter.next()) { + (None, _) => Self::Run { start: 0, end: Some(0) }, + (Some(run), None) => Self::Run { + start: *run.start(), + end: run.end().checked_add(1), + }, + _ => Self::Runs(Arc::new(runs)), + } + } } /// The newest servable group that has presented at least one frame. @@ -4732,9 +4852,8 @@ struct Cursor { datagram_index: usize, /// Minimum sequence to return from any `recv` method. Set by `start_at`. min_sequence: u64, - /// Whether the subscription's floor still waits on the track's first group: a range - /// counting back from the latest group cannot resolve before one exists. - floor_pending: bool, + /// This subscriber's own ranges, resolved. Boxed: every reader embeds a cursor. + filter: Box, /// One past the highest sequence returned by `next_group`, or `None` once it returned /// `u64::MAX` and no group can follow. /// Used only by that method to skip late arrivals; does not affect `recv_group`. @@ -4745,22 +4864,14 @@ struct Cursor { /// the producer's cache and become eligible again when the cap rises (or is removed). /// `Some(0)` is the empty range. end_sequence: Option, - /// Groups received beyond the [`Self::end_sequence`] cap, held for `recv_group` - /// until the cap rises (arrival-order reads consume the shared cursor, so they - /// are parked here instead of dropped). Keyed by sequence so the lowest is - /// re-offered first. - parked: BTreeMap, - aborts: Option>, - /// [`Self::end_sequence`], shared with the groups this cursor hands out so their - /// expiry measures drift against the same servable window after a cap moves. - /// - /// Deliberately not where [`Subscription::ranges`] end. That is a *request to the publisher*, - /// folded in with every other subscriber's, and it does not filter this handle (see - /// [`Subscriber`]): another unbounded subscriber widens the aggregate and the groups - /// arrive here anyway. Capping the drift edge with it would pin the live edge at the - /// requested end while delivery ran past it, and everything above would then have - /// nothing newer to be late against. - drift_cap: kio::Producer>, + /// What this cursor can be served, the only groups its drift is measured within: its + /// window below its cap, so a range behind the live edge is judged against its own + /// newest group, and a group in a gap between ranges against nothing. + servable: Servable, + /// [`Self::servable`], shared with the groups this cursor hands out so their expiry + /// measures drift against the same groups after the cap or ranges move, and with its + /// [`Control`]s, which republish it when they replace the ranges. + published: kio::Producer, /// Groups the drift budget skipped since the count was last drained. Accumulated /// here rather than metered in place because the stats scope lives on the owning /// [`Subscriber`], which drains this after every read. @@ -4772,6 +4883,13 @@ struct Cursor { drops: Option>, } +/// The sequences a [`DropCursor`] reports from: a cursor's span, narrowed to its window. +struct Within<'a> { + span: ops::Range, + window: &'a Window, + revision: u64, +} + /// What a wire publisher's cursor gave up, found as it changes rather than by rescanning /// the cache on every poll. struct DropCursor { @@ -4780,9 +4898,10 @@ struct DropCursor { cuts: super::aborts::Watch, /// Sequences given up and not reported yet: stale skips, cuts, and upstream drops. pending: RangeSet, - /// The [`TrackState::drops_revision`] and window last folded into `pending`. + /// The [`TrackState::drops_revision`], span, and window revision last folded into + /// `pending`. #[cfg_attr(not(feature = "async"), allow(dead_code))] - scanned: Option<(u64, ops::Range)>, + scanned: Option<(u64, ops::Range, u64)>, /// Every sequence this cursor already reported. #[cfg_attr(not(feature = "async"), allow(dead_code))] reported: RangeSet, @@ -4801,7 +4920,8 @@ impl DropCursor { } /// The lowest unreported run of `within` given up, now marked reported. - fn next(&mut self, state: &TrackState, cut: BTreeSet, within: &ops::Range) -> Option> { + fn next(&mut self, state: &TrackState, cut: BTreeSet, within: &Within<'_>) -> Option> { + let (span, window) = (&within.span, within.window); for sequence in cut { // A cut already reclaimed from the cache is in the track's drops instead. if state.is_cut(sequence) @@ -4812,14 +4932,14 @@ impl DropCursor { } // Rescan only after the drops change or the window moves; out-of-window runs were // skipped and may be owed now. - let scan = (state.drops_revision, within.clone()); + let scan = (state.drops_revision, span.clone(), within.revision); if self.scanned.as_ref() != Some(&scan) { self.scanned = Some(scan); - for dropped in state.drops.overlapping(within) { + for dropped in state.drops.overlapping(span) { self.pending - .insert(dropped.start.max(within.start)..dropped.end.min(within.end)); + .insert(dropped.start.max(span.start)..dropped.end.min(span.end)); } - for (&sequence, slot) in state.lookup.range(within.clone()) { + for (&sequence, slot) in state.lookup.range(span.clone()) { if slot.visible && slot.group.is_cut() && let Some(end) = sequence.checked_add(1) @@ -4830,9 +4950,13 @@ impl DropCursor { } while let Some(range) = self.pending.iter().next().cloned() { self.pending.remove(range.clone()); - let clipped = range.start.max(within.start)..range.end.min(within.end); - if clipped.is_empty() { + // Only what this subscriber asked for is owed: the first run of its ranges in it, + // the rest of the range waiting its turn. + let Some(clipped) = window.clip(range.start.max(span.start)..range.end.min(span.end)) else { continue; + }; + if clipped.end < range.end { + self.pending.insert(clipped.end..range.end); } if let Some(gap) = self.reported.gaps(&clipped).next() { if gap.end < range.end { @@ -4846,6 +4970,24 @@ impl DropCursor { } } +/// A cursor's own ranges, resolved, and what keeps them current. +struct Filter { + /// Only the groups in it are read. + window: Window, + /// The [`Preferences::ranges_revision`] the window was resolved from. + resolved: u64, + /// Bumped whenever the window moves, so the drop scan knows to look again. + revision: u64, + /// What the arrival walk reads: the window from the floor, below the cap. The walk + /// passes a group outside it holding nothing, so widening it re-scans the cache. + admitted: RangeInclusiveSet, + /// The floor, [`Self::revision`], and cap [`Self::admitted`] was taken at. + admitted_at: Option<(u64, u64, Option)>, + /// The spans a widening newly admitted, scanned in the cache lowest first and handed + /// out ahead of the walk: each cached group in them is visited once. + owed: RangeInclusiveSet, +} + impl Cursor { fn new(state: kio::Consumer, subscription: Preferences) -> Self { // The ranges floor the cursor, and the budget picks where above it to start, timed @@ -4854,10 +4996,11 @@ impl Cursor { if subscription.read().joins_live() { TrackState::join(state.read()); } - let (floor, datagram_index) = { + let (filter, datagram_index) = { let preferences = subscription.read(); + let ranges_revision = subscription.ranges_revision(); let state = state.read(); - let floor = preferences.floor(state.newest_produced); + let window = Window::new(&preferences.ranges, state.newest_produced); // Arrival order need not be timestamp order. Only skip the initial stale prefix; // subsequent delivery stays in arrival order, including late datagrams. let newest = state.datagrams.iter().filter_map(|datagram| datagram.timestamp).max(); @@ -4873,29 +5016,44 @@ impl Cursor { }) .expect("the newest datagram is within max delay") }); - (floor, state.datagram_offset + start) + let filter = Filter { + window, + resolved: ranges_revision, + revision: 0, + admitted: RangeInclusiveSet::new(), + admitted_at: None, + owed: RangeInclusiveSet::new(), + }; + (filter, state.datagram_offset + start) }; - Self { + let mut cursor = Self { state, subscription, - min_sequence: floor.unwrap_or(0), - floor_pending: floor.is_none(), + min_sequence: 0, + filter: Box::new(filter), index: 0, datagram_index, next_sequence: Some(0), end_sequence: None, - parked: BTreeMap::new(), - aborts: None, - drift_cap: kio::Producer::new(None), + servable: Servable::ALL, + // Replaced by the cursor's own below, before anything is handed out. + published: kio::Producer::new(Published::new(Arc::default(), 0, None)), stale: stats::Content::default(), seek_pending: BTreeMap::new(), drops: None, - } + }; + cursor.update_servable(); + cursor } #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_drop(&mut self, waiter: &kio::Waiter) -> Poll> { - let within = self.min_sequence..self.end_sequence.unwrap_or(u64::MAX); + self.sync(); + let within = Within { + span: self.floor()..self.end_sequence.unwrap_or(u64::MAX), + window: &self.filter.window, + revision: self.filter.revision, + }; let drops = match &mut self.drops { Some(drops) => drops, None => self.drops.insert(Box::new(DropCursor::new(&self.state.read()))), @@ -4914,29 +5072,141 @@ impl Cursor { } } - /// Publish [`Self::end_sequence`] to the groups already handed out. - fn update_drift_cap(&mut self) { - // Skip a no-op write: every handed-out group's expiry watches this channel. - if *self.drift_cap.read() != self.end_sequence - && let Ok(mut current) = self.drift_cap.write() + /// Resolve [`Self::servable`] again once the window or cap moved, and publish it to the + /// groups already handed out. Costs the ranges, never the cache. + fn update_servable(&mut self) { + self.servable = Servable::new(self.admitted(0, self.end_sequence)); + let (revision, cap) = (self.filter.resolved, self.end_sequence); + let next = |current: &Published| { + // Ranges a `Control` republished past this window stay until this cursor syncs + // to them; the cap is this cursor's own. + let runs = match current.revision > revision { + true => current.runs.clone(), + false => Arc::new(self.admitted(0, None)), + }; + let next = Published::new(runs, revision.max(current.revision), cap); + (next != *current).then_some(next) + }; + // A no-op write is skipped: every handed-out group's expiry watches the channel. + if next(&self.published.read()).is_none() { + return; + } + if let Ok(mut current) = self.published.write() + && let Some(next) = next(¤t) { - *current = self.end_sequence; + *current = next; } } - /// Raise the floor to where the subscription's ranges start, once a group exists to - /// count a relative start back from. Returns whether the floor is settled. - fn resolve_floor(&mut self) -> bool { - if self.floor_pending { - // The track had no produced group at subscribe, so the newest one then was the - // first to arrive, however many have arrived or left since. - let latest = self.state.read().first_produced; - if let Some(floor) = self.subscription.read().floor(latest) { - self.min_sequence = self.min_sequence.max(floor); - self.floor_pending = false; + /// Bring [`Self::window`] up to date: resolved again once the subscriber replaces its + /// ranges, counting relative anchors back from the latest group then, and settled as + /// the groups it waits on arrive. + fn sync(&mut self) { + let replaced = { + let preferences = self.subscription.read(); + let revision = self.subscription.ranges_revision(); + (revision != self.filter.resolved).then(|| (revision, preferences.ranges.clone())) + }; + let state = self.state.read(); + let moved = match replaced { + Some((revision, ranges)) => { + self.filter.resolved = revision; + self.filter.window = Window::new(&ranges, state.newest_produced); + true } + // The track had no produced group when the ranges resolved, so the newest one + // then was the first to arrive, however many have arrived or left since. + None => self.filter.window.settle(state.first_produced), + }; + drop(state); + if moved { + self.filter.revision += 1; + self.update_servable(); } - !self.floor_pending + } + + /// The lowest group this cursor reads: its own floor, raised to its window's. + fn floor(&self) -> u64 { + self.min_sequence.max(self.filter.window.floor().unwrap_or(0)) + } + + /// Whether group `sequence`, at or above [`Self::floor`], is one this cursor reads now: + /// in its window and below its cap. One that isn't may be later. + fn admits(&self, sequence: u64) -> bool { + super::subscription::before_end(sequence, self.end_sequence) && self.filter.window.contains(sequence) + } + + /// The window from `from`, below the exclusive `cap`. + fn admitted(&self, from: u64, cap: Option) -> RangeInclusiveSet { + admitted(&self.filter.window, from, cap) + } + + /// [`Filter::owed`] as of now: what was owed and what the walk newly reads since + /// [`Filter::admitted`] was taken, both cut to `admitted`. + fn owed(&self, admitted: &RangeInclusiveSet) -> RangeInclusiveSet { + // Nothing is owed before the walk first reads: it starts at the oldest arrival. + if self.filter.admitted_at.is_none() { + return RangeInclusiveSet::new(); + } + let mut owed = RangeInclusiveSet::new(); + for run in admitted.iter() { + for gap in self.filter.admitted.gaps(run) { + owed.insert(gap); + } + for span in self.filter.owed.overlapping(run) { + owed.insert((*span.start()).max(*run.start())..=(*span.end()).min(*run.end())); + } + } + owed + } + + /// Take in a move of the floor, the ranges, or the cap: whatever it newly admits is + /// owed. Costs the ranges, never the cache, and only when one moved. + fn admit(&mut self) { + let at = (self.floor(), self.filter.revision, self.end_sequence); + if self.filter.admitted_at == Some(at) { + return; + } + // The arrival walk reads the window from the floor. + let admitted = self.admitted(self.floor(), self.end_sequence); + self.filter.owed = self.owed(&admitted); + self.filter.admitted = admitted; + self.filter.admitted_at = Some(at); + } + + /// The lowest cached group still owed, consuming the spans scanned to find it: only + /// one the walk could have read, never fetched backfill or a withheld group. + fn next_owed(&mut self) -> Option { + if self.filter.owed.is_empty() { + return None; + } + let state = self.state.read(); + // Nothing is read from the cache now, so leave what is owed until it is. + if !state.readable() { + return None; + } + let low = state.live_floor.unwrap_or(0); + while let Some(run) = self.filter.owed.iter().next().cloned() { + let from = (*run.start()).max(low); + let found = (from <= *run.end()) + .then(|| { + state + .lookup + .range(from..=*run.end()) + .map(|(_, slot)| slot) + .find(|slot| slot.is_walked()) + .map(Slot::serving) + }) + .flatten(); + match found { + Some(group) => { + self.filter.owed.remove(*run.start()..=group.sequence); + return Some(group.clone()); + } + None => self.filter.owed.remove(run), + } + } + None } // A helper to automatically apply Dropped if the state is closed without an error. @@ -4959,14 +5229,14 @@ impl Cursor { /// This subscriber's clamped drift budget and the live edge to measure against, /// resolved once per poll. /// - /// `cap` bounds the edge: the caller passes the same window it reads from, so a + /// `servable` bounds the edge: the caller passes the same window it reads from, so a /// group is only ever judged against content that could actually be served in its /// place. Read fresh each poll, so a mid-stream [`Control::update`] applies /// to the very next group, and shared across every candidate that poll walks off, so /// discarding a backlog of N groups costs one scan rather than N. Only ever /// [`Poll::Ready`]; the track ending surfaces as the error the caller was going to /// get anyway. - fn poll_drift(&self, cap: Option, waiter: &kio::Waiter) -> Poll> { + 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; @@ -4975,14 +5245,14 @@ impl Cursor { self.poll(waiter, |state| { Poll::Ready(Ok(Drift { budget: clamp_max_delay(max_delay, state.max_age_bound()), - edge: state.drift_edge(cap), + edge: state.drift_edge(servable), })) }) } /// Whether the drift budget says to skip `group`, against a [`Drift`] already resolved /// for this poll. - fn poll_stale(&self, group: &group::Consumer, drift: Drift, waiter: &kio::Waiter) -> Poll> { + fn poll_stale(&self, group: &group::Consumer, drift: &Drift, waiter: &kio::Waiter) -> Poll> { self.poll(waiter, |state| { Poll::Ready(Ok(state.is_stale(group.sequence, &drift.edge, drift.budget))) }) @@ -4999,21 +5269,21 @@ impl Cursor { let max_delay = self.subscription.read().max_delay; let state = self.state.read(); let budget = clamp_max_delay(max_delay, state.max_age_bound()); - let edge = state.drift_edge(self.end_sequence); + let edge = state.drift_edge(&self.servable); // Every group below the last successor found shares it, so a run of cached groups // that cannot serve is walked once per pass, not once per gap. let mut arrival: Option<&Slot> = None; let mut successor: Option<&Slot> = None; while let Some(oldest) = delivered.above_gap() { if arrival.is_none_or(|slot| slot.group.sequence <= oldest) { - arrival = state.arrival(oldest, edge.cap); + arrival = state.arrival(oldest, &edge.servable); } // Either clock, as `drifted` judges a group: without a successor, neither. let Some(first) = arrival else { break; }; if successor.is_none_or(|slot| slot.group.sequence <= oldest) { - successor = state.successor(oldest, edge.cap); + successor = state.successor(oldest, &edge.servable); } let past = state.aged(first, budget) || successor.is_some_and(|slot| { @@ -5033,80 +5303,39 @@ impl Cursor { group.with_expiry(Arc::new(GroupExpiry { state: self.state.weak(), subscription: self.subscription.consume(), - cap: self.drift_cap.consume(), + servable: self.published.consume(), sequence, })) } - /// Hold a capped group without losing another copy's abort notification. - fn park(&mut self, consumer: group::Consumer, waiter: &kio::Waiter) { - let aborts = self - .aborts - .get_or_insert_with(|| Box::new(self.state.read().cache.watch_aborts())); - // Register before checking: an abort on either side of the check is - // observed now or queued for the next poll. - aborts.insert(consumer.sequence); - if consumer.is_aborted() { - if !self.parked.contains_key(&consumer.sequence) { - aborts.remove(consumer.sequence); - } - } else { - self.parked.insert(consumer.sequence, consumer); - } - aborts.register(waiter); - } - fn poll_recv_group(&mut self, waiter: &kio::Waiter) -> Poll>> { - self.resolve_floor(); - if let Some(aborts) = &self.aborts { - // A sequence may name both a live group and its fetched head. Only the - // consumer we parked decides whether this notification invalidates it. - for sequence in aborts.poll(waiter) { - if self.parked.get(&sequence).is_some_and(group::Consumer::is_aborted) { - self.parked.remove(&sequence); - aborts.remove(sequence); - } - } - // Each overtaken entry is removed once, rather than scanning every entry - // again for every delivered group. - while let Some((&sequence, _)) = self.parked.first_key_value() - && sequence < self.min_sequence - { - self.parked.pop_first(); - aborts.remove(sequence); - } - if self.parked.is_empty() { - self.aborts = None; - } - } + self.sync(); + self.admit(); // One scan for the whole poll, so walking a backlog off stays linear in its size. - let drift = ready!(self.poll_drift(self.end_sequence, waiter))?; + let drift = ready!(self.poll_drift(&self.servable, waiter))?; + self.poll_recv_with(drift, waiter) + } + /// [`Self::poll_recv_group`] once synced, judging against `drift`. + fn poll_recv_with(&mut self, mut drift: Drift, waiter: &kio::Waiter) -> Poll>> { loop { - // Re-offer the lowest parked group back inside the cap once it rises, - // ahead of the arrival cursor: it is the oldest thing still owed. - let consumer = match self.parked.keys().next().copied() { - Some(sequence) if super::subscription::before_end(sequence, self.end_sequence) => { - let group = self.parked.remove(&sequence).expect("just looked it up"); - self.aborts - .as_ref() - .expect("parked groups are watched") - .remove(sequence); + // 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 = match self.next_owed() { + Some(producer) => { + let consumer = producer.consume(); // A re-offer is a delivery: stamp it like a fresh hand-out. - group.cache_refresh(); - group + consumer.cache_refresh(); + consumer } - _ => { + None => { + let floor = self.floor(); let Some((producer, found_index)) = - ready!(self.poll(waiter, |state| state.poll_recv_group(self.index, self.min_sequence))?) + ready!(self.poll(waiter, |state| state.poll_recv_group(self.index, floor))?) else { - // Parked groups survive a finished track: they become deliverable - // again if the cap rises, so the stream isn't over while any are held. - if self.parked.is_empty() { - return Poll::Ready(Ok(None)); - } - return Poll::Pending; + return Poll::Ready(Ok(None)); }; let consumer = producer.consume(); // Stamp with the track guard released, so delivery never nests the @@ -5116,15 +5345,22 @@ impl Cursor { // The first group to arrive resolves a relative floor, which may land // above it. - if self.floor_pending && self.resolve_floor() && consumer.sequence < self.min_sequence { - continue; + if self.filter.window.is_pending() { + self.sync(); + self.admit(); + // The snapshot saw the pending window, which serves everything. + if !self.filter.window.is_pending() { + drift = ready!(self.poll_drift(&self.servable, waiter))?; + } + if consumer.sequence < self.floor() { + continue; + } } - // Park a group beyond the cap instead of dropping it, and keep scanning - // so an in-range group that arrived behind it still flows. - if !super::subscription::before_end(consumer.sequence, self.end_sequence) { - self.park(consumer, waiter); - + // Pass a group beyond the cap or outside the ranges, holding nothing: a + // widening finds it in the cache again. Keep scanning so an in-range group + // that arrived behind it still flows. + if !self.admits(consumer.sequence) { continue; } consumer @@ -5133,7 +5369,7 @@ impl Cursor { // Drop a group the drift budget has given up on and keep scanning, so one // poll walks a whole backlog off rather than handing it out group by group. - if ready!(self.poll_stale(&consumer, drift, waiter))? { + if ready!(self.poll_stale(&consumer, &drift, waiter))? { self.stale.add(consumer.content()); if let Some(drops) = &mut self.drops && let Some(end) = consumer.sequence.checked_add(1) @@ -5160,17 +5396,14 @@ impl Cursor { return Poll::Ready(Ok(None)); }; self.datagram_index = found_index + 1; - self.resolve_floor(); + self.sync(); let sequence = datagram.sequence; let mid_group = self.subscription.read().start().is_some_and(|start| { start.group == sequence && start.frame > datagram.wire.as_ref().and_then(|wire| wire.object_id).unwrap_or(0) }); - if sequence >= self.min_sequence - && super::subscription::before_end(sequence, self.end_sequence) - && !mid_group - { + if sequence >= self.floor() && self.admits(sequence) && !mid_group { return Poll::Ready(Ok(Some(datagram))); } } @@ -5182,14 +5415,25 @@ impl Cursor { let Some(next) = self.next_sequence else { return Poll::Ready(Ok(None)); }; - self.resolve_floor(); - let mut floor = next.max(self.min_sequence); - let end = self.end_sequence; + self.sync(); + 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(end, waiter))?; + let drift = ready!(self.poll_drift(&self.servable, waiter))?; + self.poll_next_with(floor, drift, waiter) + } + /// [`Self::poll_next_group`] once synced, from `floor`, judging against `drift`. + fn poll_next_with( + &mut self, + mut floor: u64, + mut drift: Drift, + waiter: &kio::Waiter, + ) -> Poll>> { + let end = self.end_sequence; let group = loop { - let Some(producer) = ready!(self.poll(waiter, |state| state.poll_next_in_range(floor, end))?) else { + let window = &self.filter.window; + let Some(producer) = ready!(self.poll(waiter, |state| state.poll_next_in_range(floor, end, window))?) + else { // 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 @@ -5199,14 +5443,21 @@ impl Cursor { let group = producer.consume(); // The first group to arrive resolves a relative floor, which may land above it. - if self.floor_pending && self.resolve_floor() && group.sequence < self.min_sequence { - floor = floor.max(self.min_sequence); - continue; + if self.filter.window.is_pending() { + self.sync(); + // The snapshot saw the pending window, which serves everything. + if !self.filter.window.is_pending() { + drift = ready!(self.poll_drift(&self.servable, waiter))?; + } + if group.sequence < self.floor() || !self.filter.window.contains(group.sequence) { + floor = floor.max(self.floor()).max(group.sequence.saturating_add(1)); + continue; + } } // Skip a group the budget has given up on and keep scanning, so one poll // walks a whole backlog off rather than handing it out group by group. - if ready!(self.poll_stale(&group, drift, waiter))? { + if ready!(self.poll_stale(&group, &drift, waiter))? { // Not counted yet: a conviction is not permanent (the budget can widen, // the edge can be evicted), so the group may still be delivered. // Re-snapshot on every re-examination so the eventual count reflects the @@ -5255,6 +5506,41 @@ impl Cursor { #[derive(Clone)] pub struct Control { subscription: Preferences, + /// A plain cursor's servable window, republished when the ranges are replaced, so a + /// group it handed out is judged against them before the cursor next reads. `None` + /// for a reader that follows a route, whose copies take them on its next read. + republish: Option, +} + +/// What a [`Control`] needs to republish a plain cursor's [`Published`] window. +#[derive(Clone)] +struct Republish { + state: kio::ConsumerWeak, + published: kio::ProducerWeak, +} + +impl Republish { + /// Resolve the ranges in `preferences` as the cursor will, and publish them under the + /// cap the cursor holds then, unless the cursor already published them or newer. + fn publish(&self, preferences: &Preferences) { + let Some(published) = self.published.produce() else { + return; + }; + let (ranges, revision) = { + let current = preferences.read(); + (current.ranges.clone(), preferences.ranges_revision()) + }; + if published.read().revision >= revision { + return; + } + let window = Window::new(&ranges, self.state.read().newest_produced); + let runs = Arc::new(admitted(&window, 0, None)); + if let Ok(mut current) = published.write() + && current.revision < revision + { + *current = Published::new(runs, revision, current.cap); + } + } } impl Control { @@ -5268,7 +5554,11 @@ impl Control { /// Returns [`Error::Closed`] if the track already ended; the update is /// meaningless at that point and can usually be ignored. pub fn update(&self, subscription: Subscription) -> Result<()> { - self.subscription.update(subscription) + self.subscription.update(subscription)?; + if let Some(republish) = &self.republish { + republish.publish(&self.subscription); + } + Ok(()) } } @@ -5311,10 +5601,17 @@ impl Subscriber { /// Create a handle for updating this subscriber's delivery preferences. pub fn control(&self) -> Control { - Control { - subscription: match &self.inner { - Inner::Plain(cursor) => cursor.subscription.clone(), - Inner::Resume(resume, _) => resume.subscription().clone(), + match &self.inner { + Inner::Plain(cursor) => Control { + subscription: cursor.subscription.clone(), + republish: Some(Republish { + state: cursor.state.weak(), + published: cursor.published.weak(), + }), + }, + Inner::Resume(resume, _) => Control { + subscription: resume.subscription().clone(), + republish: None, }, } } @@ -5343,11 +5640,11 @@ impl Subscriber { /// The budget remains attached to a returned group: if it stalls while newer data /// advances, its pending frame read ends with [`Error::Old`]. /// - /// Honors the group range set by [`Self::set_groups`]: - /// a group beyond the cap is parked (not dropped) and re-offered once the cap rises - /// (lowest sequence first), without blocking in-range groups that arrive behind it. - /// A parked group that the producer evicts or expires in the meantime is dropped, - /// so parking never outlives the track's cache policy. + /// Honors the group range set by [`Self::set_groups`]: a group beyond the cap is + /// passed without blocking in-range groups that arrive behind it, and holds nothing + /// here. Raising the cap, or widening the ranges, offers the groups it newly covers + /// that the cache still holds, lowest sequence first. Once the track finishes, a + /// reader with nothing left in range ends. /// /// Returns `Poll::Ready(Ok(Some(group)))` when a group is available, /// `Poll::Ready(Ok(None))` when the track is finished, @@ -5415,28 +5712,43 @@ impl Subscriber { Inner::Resume(resume, _) => return resume.has_unread_group(unread), }; let state = cursor.state.read(); - let floor = cursor.min_sequence.max(state.live_floor.unwrap_or(0)).max(unread.start); + let floor = cursor.floor().max(state.live_floor.unwrap_or(0)).max(unread.start); let eligible = |sequence: u64| { sequence >= floor && super::subscription::before_end(sequence, unread.end) + && cursor.filter.window.contains(sequence) && !unread.delivered.contains(sequence) }; - cursor - .parked - .iter() - .any(|(sequence, group)| eligible(*sequence) && !group.is_aborted()) - || (state.readable() - && state - .arrival - .iter() - .skip(cursor.index.saturating_sub(state.offset)) - .any(|(sequence, stamp)| { - eligible(*sequence) - && state - .lookup - .get(sequence) - .is_some_and(|slot| slot.stamp == *stamp && !slot.is_aborted()) - })) + // What a widening owes, the walk has yet to take in, or lifting the local cap would: + // a group past it is unread, not gone. + let owed = cursor.owed(&cursor.admitted(cursor.floor(), None)); + let last = unread.end.map(|end| end.checked_sub(1)); + let owed = || { + owed.iter().any(|run| { + let end = match last { + Some(Some(last)) => (*run.end()).min(last), + Some(None) => return false, + None => *run.end(), + }; + *run.start() <= end + && state + .lookup + .range(*run.start()..=end) + .any(|(sequence, slot)| eligible(*sequence) && slot.is_walked()) + }) + }; + state.readable() + && (state + .arrival + .iter() + .skip(cursor.index.saturating_sub(state.offset)) + .any(|(sequence, stamp)| { + eligible(*sequence) + && state + .lookup + .get(sequence) + .is_some_and(|slot| slot.stamp == *stamp && !slot.is_aborted()) + }) || owed()) } /// Receive the next group in arrival order. @@ -5545,8 +5857,55 @@ impl Subscriber { Inner::Resume(resume, _) => return resume.poll_start(waiter), }; let start = ready!(Consumer::poll_state_start(&cursor.state, waiter)); - cursor.resolve_floor(); - Poll::Ready(start.map(|start| start.max(cursor.min_sequence))) + cursor.sync(); + Poll::Ready(start.map(|start| start.max(cursor.floor()))) + } + + /// The lowest group this subscriber's ranges name, as resolved here when it last read: + /// `None` while one reaches back to the oldest group still around, or a relative + /// start still waits on the first group to arrive. + /// + /// Where a reader that waits on each group in sequence starts, rather than at whatever + /// arrives first, which newest-first delivery makes the latest. + pub fn floor(&self) -> Option { + match &self.inner { + Inner::Plain(cursor) => cursor.filter.window.start(), + Inner::Resume(resume, _) => resume.floor(), + } + } + + /// The lowest group at or above `from` this subscriber's ranges cover, `None` when + /// none does: the groups between are never read, so a reader waiting on each group in + /// sequence need not wait for them. `Some(from)` while a relative anchor waits on the + /// first group, which can't say yet. + pub fn next_in_range(&self, from: u64) -> Option { + match &self.inner { + Inner::Plain(cursor) => cursor.filter.window.runs(from).next().map(|run| *run.start()), + Inner::Resume(resume, _) => resume.next_in_range(from), + } + } + + /// This subscriber's own ranges, by index, as resolved here: `None` for one empty or + /// still waiting, and for every range of a front's reader. + pub(super) fn resolved_ranges(&mut self) -> Vec> { + match &mut self.inner { + Inner::Plain(cursor) => { + cursor.sync(); + cursor.filter.window.resolved() + } + Inner::Resume(..) => Vec::new(), + } + } + + /// `ranges` resolved against this track's cache now, by index. + pub(super) fn resolve_ranges(&self, ranges: &[Range]) -> Vec> { + match &self.inner { + Inner::Plain(cursor) => { + let state = cursor.state.read(); + Window::new(ranges, state.newest_produced).resolved() + } + Inner::Resume(..) => Vec::new(), + } } /// Poll for sequences this subscription gave up without serving. @@ -5608,9 +5967,9 @@ impl Subscriber { /// /// `2..=5` includes groups 2 through 5; `2..5` excludes group 5. An omitted /// start preserves the current floor, and an omitted end removes the cap. - /// Groups above the end remain buffered and can be read after raising the cap. + /// Groups above the end stay cached and can be read after raising the cap. /// [`Self::poll_largest`] reports within these bounds. - /// This changes only local delivery; use [`Subscription::ranges`] and + /// This narrows only local delivery, within [`Subscription::ranges`]; use those and /// [`Self::update`] to change the requested groups. pub fn set_groups(&mut self, groups: impl RangeBounds) { let (start, end) = super::subscription::sequence_bounds(groups); @@ -5622,7 +5981,7 @@ impl Subscriber { /// /// A local filter, not a request: it doesn't tell the publisher anything, so the /// skipped groups are still delivered and simply not returned. To ask the publisher - /// to start there instead, set [`Subscription::ranges`] via [`Self::update`]. + /// to start there too, set [`Subscription::ranges`] via [`Self::update`]. /// See [Local cursor vs wire preference](Self#local-cursor-vs-wire-preference). #[cfg_attr(not(feature = "async"), allow(dead_code))] // pending: /quest/m1/public-model/serve.md @@ -5656,16 +6015,16 @@ impl Subscriber { /// A local filter, not a request; [`Subscription::ranges`] are the wire-level /// counterpart. See [Local cursor vs wire preference](Self#local-cursor-vs-wire-preference). /// - /// Groups beyond the cap are held rather than skipped past, so a later call to - /// [`Self::set_groups`] with a higher bound (or unbounded) makes them available again. - /// Lowering the cap below the consumer's current cursor parks the consumer until the - /// cap is raised. + /// Groups beyond the cap stay in the cache, so a later call to [`Self::set_groups`] + /// with a higher bound (or unbounded) offers the ones still cached. Lowering the cap + /// below the consumer's current cursor waits until the cap is raised, or the track + /// finishes. // pending: /quest/m1/public-model/serve.md pub(crate) fn end_at(&mut self, end: impl Into) { match &mut self.inner { Inner::Plain(cursor) => { cursor.end_sequence = end.into().exclusive(); - cursor.update_drift_cap(); + cursor.update_servable(); } Inner::Resume(resume, _) => resume.end_at(end.into()), } @@ -5691,14 +6050,22 @@ impl Subscriber { /// Replace this subscriber's delivery preferences. /// /// Stored verbatim; the publisher's max age window is applied to the aggregate, not - /// here (see [`Producer::subscription`]). Returns [`Error::Closed`] if the track - /// already ended; the update is meaningless at that point and can usually be ignored. + /// here (see [`Producer::subscription`]). Replaced ranges resolve again here, a + /// relative anchor counting back from the latest group now, and widening them offers + /// the cached groups they newly cover, even ones that arrived before the groups + /// already read. Returns [`Error::Closed`] + /// if the track already ended; the update is meaningless at that point and can usually + /// be ignored. pub fn update(&mut self, subscription: Subscription) -> Result<()> { let channel = match &self.inner { Inner::Plain(cursor) => &cursor.subscription, Inner::Resume(resume, _) => resume.subscription(), }; - channel.update(subscription) + channel.update(subscription)?; + if let Inner::Plain(cursor) = &mut self.inner { + cursor.sync(); + } + Ok(()) } /// Poll for the track's largest location within [`Self::set_groups`]: the current one, then each newer one, and `None` once the track ends. @@ -9548,7 +9915,7 @@ mod test { let state = producer.state.read(); let drift = Drift { budget: Duration::ZERO, - edge: state.drift_edge(None), + edge: state.drift_edge(&Servable::ALL), }; assert!( state.is_stale(0, &drift.edge, drift.budget), @@ -9593,18 +9960,180 @@ mod test { #[test] #[cfg(feature = "async")] - fn a_requested_end_does_not_cap_the_live_edge() { + fn a_range_behind_the_live_edge_keeps_its_newest_group() { let mut producer = track_producer("test", Info::timed()); - for second in 0..4 { + for second in 0..=10 { append_at(&mut producer, second * 1000); } - // A range's end is a request to the publisher, folded in with every other - // subscriber's, and it does not filter this handle: the groups above it arrive - // anyway. Capping the live edge with it would pin the edge below them, leaving - // everything past it with nothing newer to be late against. - let mut subscriber = producer.subscribe(Subscription::default().with_end(Position::after_group(1))); - assert_eq!(drain(&mut subscriber), vec![3]); + // Drift is measured under where the range ends, so with no budget its own newest + // group is still fresh: the groups past it are not ones it reads. + let mut subscriber = producer.subscribe(Subscription::default().with_groups(0..2)); + assert_eq!(drain(&mut subscriber), vec![1]); + let mut subscriber = producer.subscribe(Subscription::default().with_groups(0..2)).ordered(); + let next = subscriber.next_group().now_or_never().unwrap().unwrap().unwrap(); + assert_eq!(next.sequence, 1); + + // A budget that covers it delivers the range, and nothing above it. + let mut subscriber = producer.subscribe(replay().with_end(Position::after_group(1))); + assert_eq!(drain(&mut subscriber), vec![0, 1]); + } + + /// Drift is measured only against groups the ranges cover: a group in the gap between + /// two spans is never read, so it neither sets the edge nor replaces the newest group + /// below it. A group in the later span is read, so it does. + #[test] + #[cfg(feature = "async")] + fn a_gap_between_ranges_is_not_a_drift_edge() { + let ranges = || Subscription::default().with_ranges([Range::groups(0..2), Range::groups(10..12)]); + let producer = track_producer("test", Info::timed()); + for (sequence, millis) in [(0, 0), (1, 1000), (9, 9000)] { + let mut group = producer.create_group(group::Info { sequence }).unwrap(); + group + .write_frame(Timestamp::from_millis(millis).unwrap(), bytes::Bytes::from_static(b"x")) + .unwrap(); + group.finish().unwrap(); + } + // With no budget, the newest group the ranges cover is the edge. + assert_eq!(drain(&mut producer.subscribe(ranges())), vec![1]); + + let mut ten = producer.create_group(group::Info { sequence: 10 }).unwrap(); + ten.write_frame(Timestamp::from_millis(10_000).unwrap(), bytes::Bytes::from_static(b"x")) + .unwrap(); + ten.finish().unwrap(); + assert_eq!(drain(&mut producer.subscribe(ranges())), vec![10]); + } + + /// A group handed out before the ranges moved above it is judged against the new run, + /// not a cached group below its start: here group 1 would put group 0 18s behind the + /// edge, past the budget, where the run's own group 10 puts it 9s behind. + #[moq_clock::test] + #[cfg(feature = "async")] + async fn a_held_group_is_judged_against_the_moved_run() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let budget = Subscription::default().with_max_delay(Duration::from_secs(15)); + let producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(budget.clone()); + let mut zero = producer.create_group(group::Info { sequence: 0 }).unwrap(); + zero.write_frame(at(0), bytes::Bytes::from_static(b"x")).unwrap(); + let mut held = subscriber.recv_group().await.unwrap().expect("group 0"); + assert!(held.read_frame().await.unwrap().is_some()); + + subscriber.update(budget.with_groups(10..20)).unwrap(); + for (sequence, millis) in [(1, 1000), (10, 10_000), (19, 19_000)] { + let mut group = producer.create_group(group::Info { sequence }).unwrap(); + group.write_frame(at(millis), bytes::Bytes::from_static(b"x")).unwrap(); + group.finish().unwrap(); + } + + let pending = moq_clock::sim::spawn(async move { held.read_frame().await }); + moq_clock::sim::yield_now().await; + assert!(!pending.is_finished(), "group 0 is within the budget of its run"); + } + + /// A drift snapshot taken while the window waits on its first group is taken again once + /// that group settles it. Groups 0 and 1 land between the snapshot and the arrival: the + /// settled `0..=0` judges group 0 against itself, not group 1, which it never reads. + #[test] + fn the_first_arrival_retakes_a_pending_drift_snapshot() { + for ordered in [false, true] { + let mut producer = track_producer("test", Info::timed()); + let subscription = Subscription::default() + .with_max_delay(Duration::ZERO) + .with_ranges([Range { + start: Anchor::Absolute(Position::group(0)), + end: Some(Anchor::Relative(0)), + }]); + let mut subscriber = producer.subscribe(subscription); + let Inner::Plain(cursor) = &mut subscriber.inner else { + unreachable!("a local subscriber reads its own cursor"); + }; + let waiter = kio::Waiter::noop(); + cursor.sync(); + assert!(cursor.filter.window.is_pending()); + let Poll::Ready(Ok(drift)) = cursor.poll_drift(&cursor.servable, &waiter) else { + unreachable!("a drift snapshot is always ready"); + }; + + append_at(&mut producer, 0); + append_at(&mut producer, 1000); + let res = match ordered { + true => cursor.poll_next_with(0, drift, &waiter), + false => cursor.poll_recv_with(drift, &waiter), + }; + let Poll::Ready(Ok(Some(group))) = res else { + panic!("group 0 is the newest group its range reads (ordered: {ordered})"); + }; + assert_eq!(group.sequence, 0); + } + } + + /// A [`Control`] replacing the ranges and the cursor moving its cap each change only + /// their own part of what handed-out groups are judged against, whichever goes first, + /// and with the cap moving between the ranges being written and republished. + #[test] + fn a_control_update_and_a_cap_never_publish_a_mismatched_pair() { + let published = |subscriber: &Subscriber| { + let Inner::Plain(cursor) = &subscriber.inner else { + unreachable!("a local subscriber reads its own cursor"); + }; + let current = cursor.published.read(); + (current.servable.clone(), current.cap) + }; + let ranges = Subscription::default().with_groups(10..20); + // Nothing in 10..20 sits below a cap at 5. + let expected = (Servable::new(RangeInclusiveSet::new()), Some(5)); + for order in ["ranges first", "cap first", "cap between"] { + let producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(Subscription::default()); + let control = subscriber.control(); + match order { + "ranges first" => { + control.update(ranges.clone()).unwrap(); + subscriber.set_groups(..5); + } + "cap first" => { + subscriber.set_groups(..5); + control.update(ranges.clone()).unwrap(); + } + _ => { + control.subscription.update(ranges.clone()).unwrap(); + subscriber.set_groups(..5); + control.republish.as_ref().unwrap().publish(&control.subscription); + } + } + assert!(published(&subscriber) == expected, "{order}"); + let _ = subscriber.poll_recv_group(&kio::Waiter::noop()); + assert!(published(&subscriber) == expected, "{order}, once the cursor syncs"); + } + } + + /// Ranges replaced through a [`Control`] republish what a held group is judged against + /// at once, before the cursor next reads: group 0 is judged against the run's own + /// group 10, not group 1, which the new ranges never read. + #[moq_clock::test] + #[cfg(feature = "async")] + async fn a_control_update_moves_the_run_a_held_group_is_judged_against() { + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let budget = Subscription::default().with_max_delay(Duration::from_secs(15)); + let producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(budget.clone()); + let mut zero = producer.create_group(group::Info { sequence: 0 }).unwrap(); + zero.write_frame(at(0), bytes::Bytes::from_static(b"x")).unwrap(); + let mut held = subscriber.recv_group().await.unwrap().expect("group 0"); + assert!(held.read_frame().await.unwrap().is_some()); + + subscriber.control().update(budget.with_groups(10..20)).unwrap(); + for (sequence, millis) in [(1, 1000), (10, 10_000), (19, 19_000)] { + let mut group = producer.create_group(group::Info { sequence }).unwrap(); + group.write_frame(at(millis), bytes::Bytes::from_static(b"x")).unwrap(); + group.finish().unwrap(); + } + + let pending = moq_clock::sim::spawn(async move { held.read_frame().await }); + moq_clock::sim::yield_now().await; + assert!(!pending.is_finished(), "group 0 is within the budget of its run"); + drop(subscriber); } #[test] @@ -9616,7 +10145,7 @@ mod test { // Capped at group 0: the route running on past the cap is data this subscriber // can never be served, so it isn't a live edge to be late against. - let mut subscriber = producer.subscribe(Subscription::default().with_end(Position::after_group(0))); + let mut subscriber = producer.subscribe(Subscription::default()); subscriber.set_groups(..1); assert_eq!(drain(&mut subscriber), vec![0]); @@ -10936,12 +11465,12 @@ mod test { ); } - /// `recv_group` (arrival order) honors the `end_at` cap by parking, like - /// `next_group`: beyond-cap groups are held, not dropped, and a raised cap - /// re-offers them, even after the track finishes. + /// `recv_group` (arrival order) honors the `end_at` cap like `next_group`: a group + /// beyond it is passed, holding nothing, and a raised cap finds it in the cache again. + /// Once the track finishes, a capped reader ends with what it could read. #[test] #[cfg(feature = "async")] - fn end_at_parks_recv_group() { + fn end_at_caps_recv_group() { let producer = track_producer("test", Info::timed()); let mut consumer = producer.subscribe(replay()); @@ -10960,21 +11489,19 @@ mod test { ); assert!(consumer.recv_group().now_or_never().is_none(), "capped at 2"); - // A finished track keeps the parked group claimable: the cap may rise. - producer.finish().unwrap(); - assert!( - consumer.recv_group().now_or_never().is_none(), - "still parked after finish" - ); - consumer.set_groups(..); assert_eq!( consumer.recv_group().now_or_never().unwrap().unwrap().unwrap().sequence, 2 ); + + consumer.set_groups(..3); + producer.create_group(group::Info { sequence: 3 }).unwrap(); + assert!(consumer.recv_group().now_or_never().is_none(), "capped at 3"); + producer.finish().unwrap(); assert!( matches!(consumer.recv_group().now_or_never(), Some(Ok(None))), - "finished once the parked group drains" + "a finished track ends a capped reader" ); } @@ -11086,52 +11613,6 @@ mod test { ); } - /// Eviction aborts a parked group behind a sleeping subscriber's back. Nothing - /// else will poll it (the track already finished), so the entry has to carry a - /// waiter or the subscription sleeps forever holding its stream open. - #[test] - #[cfg(feature = "async")] - fn evicted_parked_group_wakes_the_clean_end() { - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::task::{Context, Wake}; - - /// A waker that counts its wakes, for asserting a pending poll left a live - /// registration behind. - struct CountWaker(AtomicUsize); - impl Wake for CountWaker { - fn wake(self: std::sync::Arc) { - self.0.fetch_add(1, Ordering::SeqCst); - } - } - - let producer = track_producer("test", Info::timed()); - let mut consumer = producer.subscribe(None); - - producer.create_group(group::Info { sequence: 0 }).unwrap(); - assert_eq!( - consumer.recv_group().now_or_never().unwrap().unwrap().unwrap().sequence, - 0 - ); - - consumer.set_groups(..1); - let straggler = producer.create_group(group::Info { sequence: 1 }).unwrap(); - assert!(consumer.recv_group().now_or_never().is_none(), "parked at the cap"); - producer.finish().unwrap(); - - let counter = std::sync::Arc::new(CountWaker(AtomicUsize::new(0))); - let waker = std::task::Waker::from(counter.clone()); - let mut cx = Context::from_waker(&waker); - let mut fut = std::pin::pin!(consumer.recv_group()); - assert!( - fut.as_mut().poll(&mut cx).is_pending(), - "the parked group holds it open" - ); - - straggler.abort(Error::Old).unwrap(); - assert!(counter.0.load(Ordering::SeqCst) > 0, "the eviction wakeup was lost"); - assert!(matches!(fut.as_mut().poll(&mut cx), Poll::Ready(Ok(None)))); - } - /// An exclusive cap at 0 is the empty range: no group is delivered, even group 0. #[test] #[cfg(feature = "async")] @@ -11162,7 +11643,7 @@ mod test { fn empty_local_cap_holds_while_another_subscriber_requests_everything() { let producer = track_producer("test", Info::timed()); let mut everything = producer.subscribe(replay()); - let mut empty = producer.subscribe(replay().with_end(Position::group(0))); + let mut empty = producer.subscribe(replay()); empty.set_groups((Bound::Unbounded, Position::group(0).group_end())); for s in 0..3 { @@ -11828,47 +12309,6 @@ mod test { ); } - #[test] - fn an_aborted_candidate_keeps_the_parked_copy_watched() { - let producer = track_producer("test", Info::timed()); - let live = producer.create_group(group::Info { sequence: 0 }).unwrap(); - let mut subscriber = producer.subscribe(replay()); - subscriber.set_groups(..0); - let waiter = kio::Waiter::noop(); - assert!(subscriber.poll_recv_group(&waiter).is_pending()); - - // A second copy can abort after lookup but before the cursor parks it. - let candidate = group::Info { sequence: 0 }.produce(); - let aborted = candidate.consume(); - candidate.abort(Error::Old).unwrap(); - let Inner::Plain(cursor) = &mut subscriber.inner else { - unreachable!() - }; - cursor.park(aborted, &waiter); - - live.abort(Error::Old).unwrap(); - producer.finish().unwrap(); - assert!(matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Ok(None)))); - } - - #[test] - fn an_abort_removes_a_non_prefix_parked_entry() { - let producer = track_producer("test", Info::timed()); - let groups: Vec<_> = (0..3) - .map(|sequence| producer.create_group(group::Info { sequence }).unwrap()) - .collect(); - let mut subscriber = producer.subscribe(replay()); - subscriber.set_groups(..0); - let waiter = kio::Waiter::noop(); - assert!(subscriber.poll_recv_group(&waiter).is_pending()); - groups[1].clone().abort(Error::Old).unwrap(); - assert!(subscriber.poll_recv_group(&waiter).is_pending()); - let Inner::Plain(cursor) = &subscriber.inner else { - unreachable!() - }; - assert_eq!(cursor.parked.keys().copied().collect::>(), vec![0, 2]); - } - /// The live copy ending early (its subscription went away) leaves a fetched head /// that still holds the whole group servable, instead of reclaiming the slot. #[moq_clock::test] @@ -12971,6 +13411,56 @@ mod test { .unwrap(); } + /// Lowering the floor re-offers only what the arrival walk could have read: a fetched + /// group stays out of arrival order, and a withheld one waits until it is revealed. + #[moq_clock::test] + #[cfg(feature = "async")] + async fn a_lowered_floor_reoffers_neither_fetched_nor_withheld_groups() { + let (producer, _pool) = pooled_producer(1 << 40); + let dynamic = producer.dynamic(); + let consumer = producer.consume(); + producer.create_group(5u64.into()).unwrap().finish().unwrap(); + let mut subscriber = producer.subscribe(replay().with_groups(5..)); + assert_eq!(subscriber.assert_group().sequence, 5); + + let pending = consumer.fetch_group(2, None); + let req = dynamic + .requested_group() + .now_or_never() + .expect("should not block") + .unwrap(); + let mut fetched = req.accept(Info::timed()).unwrap(); + fetched + .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"backfill")) + .unwrap(); + fetched.finish().unwrap(); + pending.await.unwrap(); + let mut withheld = producer.receive_group(group::Info { sequence: 3 }).unwrap(); + + subscriber.update(replay().with_groups(0..)).unwrap(); + subscriber.assert_no_group(); + + withheld + .write_frame(Timestamp::from_millis(3).unwrap(), bytes::Bytes::from_static(b"x")) + .unwrap(); + producer.reveal_group(&withheld); + assert_eq!(subscriber.assert_group().sequence, 3, "shown once revealed"); + subscriber.assert_no_group(); + + // A cursor floor lowered before the next read counts only shown groups as unread. + let mut lowered = producer.subscribe(replay()); + lowered.start_at(5); + assert_eq!(lowered.assert_group().sequence, 5); + lowered.start_at(0); + let delivered = super::super::delivered::Delivered::default(); + let unread = Unread { + start: 0, + end: Some(3), + delivered: &delivered, + }; + assert!(!lowered.has_unread_group(&unread), "the fetched group 2 is not unread"); + } + /// A fetched (backfill) group is served by sequence but never replayed to /// arrival-order subscribers. #[moq_clock::test] @@ -13921,4 +14411,144 @@ mod test { group.write_frame(None, &b"x"[..]).unwrap(); } } + + /// A subscriber reads only its own ranges, whatever another subscriber's widen the + /// aggregate to, in arrival order and in sequence order alike. + #[test] + #[cfg(feature = "async")] + fn a_subscriber_reads_only_its_own_ranges() { + let mut producer = track_producer("test", Info::timed()); + let mut everything = producer.subscribe(replay()); + let ranges = [Range::groups(1..3), Range::groups(5..)]; + let mut mine = producer.subscribe(replay().with_ranges(ranges)); + let mut ordered = producer.subscribe(replay().with_ranges(ranges)).ordered(); + for second in 0..7 { + append_at(&mut producer, second * 1000); + } + assert_eq!(drain(&mut everything), vec![0, 1, 2, 3, 4, 5, 6]); + assert_eq!(drain(&mut mine), vec![1, 2, 5, 6]); + assert_eq!(drain_ordered(&mut ordered), vec![1, 2, 5, 6]); + } + + /// Replacing the ranges re-offers what the old ones held back, and a relative anchor + /// counts back from the latest group as of the update, not the subscribe. + #[test] + #[cfg(feature = "async")] + fn replaced_ranges_resolve_again() { + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_groups(0..2)); + for second in 0..4 { + append_at(&mut producer, second * 1000); + } + assert_eq!(drain(&mut subscriber), vec![0, 1]); + + subscriber.update(replay().with_groups(0..)).unwrap(); + assert_eq!(drain(&mut subscriber), vec![2, 3], "held back, now in range"); + + // The latest group is 3 now, so one back is 2, already read: only new groups follow. + let mut late = producer.subscribe(replay().with_groups(..1)); + assert_eq!(drain(&mut late), vec![0]); + late.update(replay().with_ranges([Range { + start: Anchor::Relative(1), + end: None, + }])) + .unwrap(); + assert_eq!(drain(&mut late), vec![2, 3]); + append_at(&mut producer, 4000); + assert_eq!(drain(&mut late), vec![4]); + } + + /// A narrow reader under an open aggregate passes the groups outside its range holding + /// nothing for them, however many arrive, and widening its ranges still serves the + /// ones the cache holds in the new span, lowest first and once each. + #[test] + #[cfg(feature = "async")] + fn a_narrow_reader_holds_nothing_for_excluded_groups() { + let producer = track_producer("test", Info::timed()); + let _wide = producer.subscribe(replay()); + let mut narrow = producer.subscribe(replay().with_groups(0..2)); + for sequence in 0..1000 { + producer.create_group(group::Info { sequence }).unwrap(); + } + assert_eq!(drain(&mut narrow), vec![0, 1]); + let Inner::Plain(cursor) = &narrow.inner else { + unreachable!() + }; + assert!(cursor.filter.owed.is_empty(), "nothing owed for excluded groups"); + assert_eq!(cursor.filter.admitted.iter().count(), 1, "only the range is kept"); + + narrow + .update(replay().with_ranges([Range::groups(0..2), Range::groups(5..8)])) + .unwrap(); + assert_eq!(drain(&mut narrow), vec![5, 6, 7]); + narrow.set_groups(..9); + narrow.update(replay().with_groups(0..)).unwrap(); + assert_eq!(drain(&mut narrow), vec![2, 3, 4, 8]); + narrow.set_groups(..); + assert_eq!(drain(&mut narrow), (9..1000).collect::>()); + } + + /// Lowering the floor re-offers the cached groups the arrival walk passed below it, + /// as widening any range does. + #[test] + #[cfg(feature = "async")] + fn a_lowered_floor_reoffers_what_arrived_below_it() { + let mut producer = track_producer("test", Info::timed()); + for second in 0..6 { + append_at(&mut producer, second * 1000); + } + let mut subscriber = producer.subscribe(replay().with_groups(5..)); + assert_eq!(drain(&mut subscriber), vec![5]); + + subscriber.update(replay().with_groups(2..)).unwrap(); + assert_eq!( + drain(&mut subscriber), + vec![2, 3, 4], + "passed below the floor, now in range" + ); + subscriber.update(replay().with_groups(0..)).unwrap(); + assert_eq!(drain(&mut subscriber), vec![0, 1]); + + // Raising and lowering it again re-offers only what is cached in between, and + // nothing already served goes out twice. + subscriber.update(replay().with_groups(4..)).unwrap(); + assert_eq!(drain(&mut subscriber), Vec::::new()); + subscriber.update(replay().with_groups(0..)).unwrap(); + assert_eq!(drain(&mut subscriber), Vec::::new()); + append_at(&mut producer, 6000); + assert_eq!(drain(&mut subscriber), vec![6]); + } + + /// Datagrams outside a subscriber's ranges are dropped like the groups. + #[test] + #[cfg(feature = "async")] + fn datagrams_outside_the_ranges_are_skipped() { + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_ranges([Range::groups(1..2), Range::groups(3..)])); + for millis in 0..5 { + producer + .append_datagram(Timestamp::from_millis(millis).unwrap(), &b"x"[..]) + .unwrap(); + } + let mut sequences = Vec::new(); + while let Some(Ok(Some(datagram))) = subscriber.recv_datagram().now_or_never() { + sequences.push(datagram.sequence); + } + assert_eq!(sequences, vec![1, 3, 4]); + } + + /// Only the sequences a subscriber asked for are named dropped to it. + #[test] + #[cfg(feature = "async")] + fn drops_are_named_only_within_the_ranges() { + let producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_ranges([Range::groups(2..4), Range::groups(6..8)])); + producer.drop_groups(0..10); + let waiter = kio::Waiter::noop(); + let mut dropped = Vec::new(); + while let Poll::Ready(range) = subscriber.poll_drop(&waiter) { + dropped.push(range); + } + assert_eq!(dropped, vec![2..4, 6..8]); + } } diff --git a/rs/moq-net/src/model/track/demand.rs b/rs/moq-net/src/model/track/demand.rs index e1d03c52fbd..91fc8abae00 100644 --- a/rs/moq-net/src/model/track/demand.rs +++ b/rs/moq-net/src/model/track/demand.rs @@ -1,5 +1,13 @@ //! Incremental demand, separate from the track's cached content. -use std::{collections::BTreeMap, sync::Arc, task::Poll, time::Duration}; +use std::{ + collections::BTreeMap, + sync::{ + Arc, + atomic::{self, AtomicU64}, + }, + task::Poll, + time::Duration, +}; use super::{Order, Sequence, Subscription, TrackState, clamp_combined}; @@ -183,6 +191,8 @@ pub(in crate::model) struct Preferences(Arc); struct Preference { value: kio::Producer, subscriptions: Option>, + /// Bumped under the write whenever the ranges change. + ranges_revision: AtomicU64, } impl Preferences { @@ -196,6 +206,7 @@ impl Preferences { Self(Arc::new(Preference { value: kio::Producer::new(value), subscriptions, + ranges_revision: AtomicU64::new(0), })) } @@ -209,6 +220,9 @@ impl Preferences { .subscriptions .as_ref() .map(|subs| subs.lock().replace(Some(&value), Some(&next))); + if value.ranges != next.ranges { + self.0.ranges_revision.fetch_add(1, atomic::Ordering::Relaxed); + } *value = next; drop(value); if let Some(wakes) = &mut wakes { @@ -225,6 +239,11 @@ impl Preferences { pub(in crate::model) fn read(&self) -> kio::Ref<'_, Subscription> { self.0.value.read() } + /// Counts changes to the ranges, so a reader can tell them apart without comparing every + /// range. Read it while holding [`Self::read`] to get the count matching those ranges. + pub(in crate::model) fn ranges_revision(&self) -> u64 { + self.0.ranges_revision.load(atomic::Ordering::Relaxed) + } pub(in crate::model) fn consume(&self) -> kio::Consumer { self.0.value.consume() } @@ -255,6 +274,7 @@ impl From> for Preferences { Self(Arc::new(Preference { value, subscriptions: None, + ranges_revision: AtomicU64::new(0), })) } } diff --git a/swift/MIGRATE.md b/swift/MIGRATE.md index f4ee0968d8c..4a3ab3425d5 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -4,6 +4,10 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Ranges filter reads (#5929) + +A subscription now reads only the groups its own `ranges` cover. + ### Track demand reports the wanted group (#5930) `TrackDemand.isUsed`, `used()`, and `unused()` are gone.