From 955969b470afbeddff47af4e7285720170a7400b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 12:29:46 -0700 Subject: [PATCH 01/20] quest(m1): replace SUBSCRIBE_ALL with a TRACKS listing The maintainer chose pull over push on 2026-10-19: a moq-lite TRACKS request lists a broadcast's tracks and the subscriber SUBSCRIBEs per track, so no relay merges pushed feeds into pulled copies. SUBSCRIBE_TRACKS maps both ways, learning an IETF peer's tracks with FORWARD=0. The SUBSCRIBE_OK start fix found in #5931 becomes its own quest. Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 3 +- quest/m1/archive/README.md | 29 ++++--- quest/m1/archive/consumer.md | 2 +- quest/m1/archive/producer.md | 24 +++--- quest/m1/archive/subscribe-all.md | 75 ----------------- quest/m1/archive/subscribe-tracks.md | 93 ++++++++++++++++----- quest/m1/archive/tracks.md | 119 +++++++++++++++++++++++++++ quest/m1/lite07-finalize.md | 8 +- quest/m1/subscribe-ok-start.md | 34 ++++++++ 9 files changed, 262 insertions(+), 125 deletions(-) delete mode 100644 quest/m1/archive/subscribe-all.md create mode 100644 quest/m1/archive/tracks.md create mode 100644 quest/m1/subscribe-ok-start.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 18f4bd1d617..b785d60194e 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -81,7 +81,7 @@ lands before the cut. - [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - `moq-mux` refuses a redefined rendition, and every Rust publisher mints a new rendition name for a decoder identity change and updates everything else in place - [Catalog too new](/quest/m1/catalog-unsupported.md) - `@moq/watch` reports a distinct status when its player cannot read the catalog version - [One hang catalog consumer](/quest/m1/catalog-consumer-fold.md) - `moq-mux` reads hang catalogs through `hang`'s consumer -- [Broadcast archives](/quest/m1/archive/README.md) - SUBSCRIBE_ALL records every track under a prefix to any object_store, a host serves a stored broadcast back, and the catalog drops its archive pointer for an opt-in `hls` field +- [Broadcast archives](/quest/m1/archive/README.md) - ANNOUNCE, TRACKS, and a SUBSCRIBE per track record every track under a prefix to any object_store, a host serves a stored broadcast back, and the catalog drops its archive pointer for an opt-in `hls` field - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish - [IETF epochs](/quest/m1/ietf-epochs.md) - negotiate explicit broadcast identity on drafts 17-22, retaining unchanged paths and epochless clients on updated relays - [Announcement takeover](/quest/m1/announce-takeover.md) - a new independent route explicitly continues instances served through its predecessor, with shared Rust/JS semantics and lite-07 wire @@ -110,6 +110,7 @@ lands before the cut. - [Timed-out requests clean up](/quest/m1/legacy-request-timeout-cleanup.md) - on drafts 14-16, Rust and JS send PUBLISH_NAMESPACE_DONE or UNSUBSCRIBE_NAMESPACE for a timed-out request before its local reset - [TS fidelity and recovery](/quest/m1/ts-reliability/README.md) - both modes have explicit profiles, automated validation, loss tests, and a dedicated guide - [Resume reorder](/quest/m1/resume-reorder.md) - a group whose stream header arrives after its successor's still resumes on failover; only a dropped or long-gone group fails +- [SUBSCRIBE_OK names where delivery starts](/quest/m1/subscribe-ok-start.md) - a warm relay names the same start group as a cold one instead of groups it skipped as stale - [Received timestamps are media time](/quest/m1/lite-time-provenance.md) - a received timed track's timestamps are media time, so the rewind check can run across hops - [WebGPU on Safari](/quest/m1/webgpu-safari.md) - the WebGPU renderer is verified on Safari 26 for macOS and iOS - [HLS export drains after End](/quest/m1/hls-export-end-drain.md) - a cleanly ended broadcast still exports its retained segments diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index be321e5fc98..920638e8c81 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -4,8 +4,9 @@ `moq-archive` stores every track of every broadcast under a path prefix into any `object_store::ObjectStore` and serves a stored broadcast back as an -ordinary broadcast, with no catalog dependency. `archive::Producer` records -from one SUBSCRIBE_ALL; `archive::Consumer` serves a stored prefix, which a +ordinary broadcast, with no catalog dependency. `archive::Producer` finds +broadcasts by ANNOUNCE, their tracks by TRACKS, and records each through its +own SUBSCRIBE; `archive::Consumer` serves a stored prefix, which a host publishes at a path of its choosing. Track, group, and frame structure and payloads survive byte for byte, so any consumer of the live broadcast, HLS included, works on the stored one. @@ -24,14 +25,16 @@ archive (per-track `.timeline.z` records named by the catalog's root path; an old prefix holds no keys the new consumer recognizes and is refused as empty. This supersedes the earlier rule to preserve the v1 and v2 formats. Add the `moq-archive` and `hang` entries to their `MIGRATE.md`. -- **Discovery is SUBSCRIBE_ALL**, a prefix-scoped moq-lite request for every - track of every broadcast under a path, gating the lite-07 cut - ([SUBSCRIBE_ALL](/quest/m1/archive/subscribe-all.md)). IETF SUBSCRIBE_TRACKS - maps onto it 1:1, and PUBLISH onto the same hook - ([SUBSCRIBE_TRACKS and PUBLISH](/quest/m1/archive/subscribe-tracks.md)): one - mechanism, and IETF clients supported. Rejected: a per-broadcast request - fanned out per announced broadcast, and exact-namespace only. The archive no - longer selects tracks from a catalog. +- **Discovery is pull**, decided 2026-10-19 in place of SUBSCRIBE_ALL's push: + ANNOUNCE finds the broadcasts under a prefix, a moq-lite TRACKS request + lists each broadcast's tracks as they appear, and the archive SUBSCRIBEs per + track, gating the lite-07 cut ([TRACKS](/quest/m1/archive/tracks.md)). + moq-transport answers SUBSCRIBE_TRACKS through the same hook and learns an + IETF peer's tracks with a FORWARD=0 SUBSCRIBE_TRACKS + ([SUBSCRIBE_TRACKS](/quest/m1/archive/subscribe-tracks.md)). Rejected: + SUBSCRIBE_ALL, whose relay had to merge pushed feeds into pulled copies + ([moq-dev/moq#5931](https://github.com/moq-dev/moq/pull/5931)). The archive + no longer selects tracks from a catalog. - **The archive holds what the cache stops serving.** Each track records through an ascending subscription whose max delay is the publisher's max age capped by configuration; a group still missing at the cap is a gap, never a @@ -70,17 +73,17 @@ Protected broadcasts stay out of scope. Recording keying, retention policy, credentials, and discovery of stored prefixes belong to the host. This README owns a new doc page, "Archives", under `doc/concept/`, written -once the children land: what SUBSCRIBE_ALL records, the store layout (linking +once the children land: what ANNOUNCE and TRACKS discover, the store layout (linking the moq-archive rustdoc that specifies it), and serving a stored broadcast from a host. Each child fixes the stale docs it touches. ## Required -- [SUBSCRIBE_ALL](/quest/m1/archive/subscribe-all.md) - one prefix-scoped moq-lite-07 request delivers every track of every broadcast under a path, including later ones, and relays fan it out from one upstream request +- [TRACKS](/quest/m1/archive/tracks.md) - a moq-lite-07 request lists a broadcast's tracks with their TRACK_INFO as they appear, for the subscriber to SUBSCRIBE per track, and relays fan it out from one upstream request - [Archive producer](/quest/m1/archive/producer.md) - `archive::Producer` stores every matching broadcast as duration-cut per-track objects keyed by group and pts, recording gaps instead of stalling, with an optional DVR mode - [Archive consumer](/quest/m1/archive/consumer.md) - `archive::Consumer` serves a stored prefix as a broadcast from its key listing, and `moq export/import archive` use the new pair - [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the root `archive` section is gone; an opt-in per-rendition `hls` field marks each aligned HLS segment's first group and pts and declares its nominal duration -- [SUBSCRIBE_TRACKS and PUBLISH](/quest/m1/archive/subscribe-tracks.md) - IETF SUBSCRIBE_TRACKS maps 1:1 onto SUBSCRIBE_ALL and PUBLISH onto its hook, instead of NOT_SUPPORTED +- [SUBSCRIBE_TRACKS](/quest/m1/archive/subscribe-tracks.md) - moq-transport SUBSCRIBE_TRACKS is answered through the TRACKS hook, and an IETF peer's tracks are learned with FORWARD=0, instead of NOT_SUPPORTED - [Fixed HLS target duration](/quest/m1/archive/hls-target.md) - one `EXT-X-TARGETDURATION` for the run, from the declared `hls` duration; an overrun is listed with a warning - [History from the retained floor](/quest/m1/archive/history-floor.md) - HLS history over an archive consumer broadcast whose start was pruned lists from its oldest stored segment diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md index 07955cfa3a9..2e1491a67fc 100644 --- a/quest/m1/archive/consumer.md +++ b/quest/m1/archive/consumer.md @@ -28,7 +28,7 @@ Decided by the maintainer on 2026-10-17 (see the permits. Advertising the source's bound would expire every archived group older than it relative to the newest. - Serve through `broadcast::Dynamic` and `track::Dynamic`. Create every listed - track up front, so a SUBSCRIBE_ALL on the served broadcast sees them all. + track up front, so TRACKS on the served broadcast lists them all. - The archived catalog is an ordinary track, so a reader sees its newest stored group, not a union of every rendition recorded. A rendition removed before the archive ends is therefore not listed at the end; the replay-catalog diff --git a/quest/m1/archive/producer.md b/quest/m1/archive/producer.md index 38bf695961b..cf9dcdd5b80 100644 --- a/quest/m1/archive/producer.md +++ b/quest/m1/archive/producer.md @@ -2,8 +2,8 @@ ## Goal -`archive::Producer` takes one SUBSCRIBE_ALL prefix subscription and stores -every track of every matching broadcast into an `object_store::ObjectStore`, +`archive::Producer` takes a path prefix and stores every track of every +broadcast under it into an `object_store::ObjectStore`, each broadcast under its own key prefix. It holds what the publisher's cache stops serving: a lost group becomes a gap and never stalls the archive. An optional DVR mode prunes old objects. It replaces `moq_archive::Writer` and @@ -14,11 +14,15 @@ its timelines and recovery machinery. Decided by the maintainer on 2026-10-17 (see the [epic](/quest/m1/archive/README.md)): -- **Input.** One prefix subscription records every broadcast under it as it - appears. The application maps each broadcast (path and epoch) to its key - prefix, so a different epoch is a different prefix and the producer stores - no epoch. Rejected: one producer per broadcast fed by per-broadcast - requests, since SUBSCRIBE_ALL is prefix-scoped. +- **Input.** One producer records every broadcast under a prefix as it + appears: ANNOUNCE finds each broadcast, [TRACKS](/quest/m1/archive/tracks.md) + lists its tracks, and each track records through its own SUBSCRIBE + (decided 2026-10-19, replacing one SUBSCRIBE_ALL). The application maps + each broadcast (path and epoch) to its key prefix, so a different epoch is + a different prefix and the producer stores no epoch. Open since discovery + became per broadcast: keep the prefix input, or take one broadcast and leave + the ANNOUNCE loop to the host. Recommended: keep the prefix, so every host + does not repeat that loop. - **Ordering.** Each track records through an ascending subscription with max delay = min(the publisher's max age from TRACK_INFO, a configured cap). A group still missing at the cap is skipped and stays absent from the store, @@ -65,8 +69,8 @@ Decided by the maintainer on 2026-10-17 (see the appended frames. A completed group yields no more frames, so this is the same rule whether or not the restart landed mid-group; if the source has dropped the rest, the stored frames are all the archive holds of it. The - start is per track: each track subscribes through the `track::Consumer` - SUBSCRIBE_ALL yields, with its own floor. Rejected: resuming after the + start is per track: each track's SUBSCRIBE carries its own floor. + Rejected: resuming after the last stored group (loses a split or open group's tail) and re-recording the last group from frame 0 (fails once the source has dropped its head). - **DVR mode** stays in the crate, for hosts without bucket lifecycle rules: @@ -94,7 +98,7 @@ and the timeline-driven types. Wire: none. ## Required -- [SUBSCRIBE_ALL](/quest/m1/archive/subscribe-all.md) - the prefix subscription it records +- [TRACKS](/quest/m1/archive/tracks.md) - how it learns each broadcast's tracks ## Related diff --git a/quest/m1/archive/subscribe-all.md b/quest/m1/archive/subscribe-all.md deleted file mode 100644 index de814093b9e..00000000000 --- a/quest/m1/archive/subscribe-all.md +++ /dev/null @@ -1,75 +0,0 @@ -# [XL] SUBSCRIBE_ALL - -## Goal - -A moq-lite-07 SUBSCRIBE_ALL subscribes to every track of every broadcast under -a path prefix. The publisher announces each track it produces, naming its -broadcast and carrying its TRACK_INFO, including tracks created after the -request and broadcasts that appear later, and delivers their groups; a -broadcast's tracks end when it ends. It never creates a track the publisher -serves only on request (an any-name `broadcast::Dynamic` handler such as a -transcoder). A relay answers many downstream SUBSCRIBE_ALLs from one upstream -request. Rust and JS both speak it. - -## Plan - -Decided by the maintainer on 2026-10-17: - -- One request, mirroring IETF SUBSCRIBE_TRACKS, so a new track's first groups - arrive on the same request that announced it. Rejected: a track-listing - stream plus a SUBSCRIBE per track (two mechanisms, and a race on a new - track's first groups) and a name filter (no consumer needs one yet). -- Prefix-scoped, like SUBSCRIBE_TRACKS, so - [SUBSCRIBE_TRACKS and PUBLISH](/quest/m1/archive/subscribe-tracks.md) maps - onto it 1:1 and moq-lite and IETF clients share one mechanism. A prefix - naming exactly one broadcast is the single-broadcast case. Rejected: a - per-broadcast request with a fan-out per announced broadcast, and - exact-namespace only. -- Gates the lite-07 cut ([Finalize moq-lite-07](/quest/m1/lite07-finalize.md)); - target `moq-lite-07-wip`. Older versions refuse the request; no emulation - over SUBSCRIBE. -- The model gains one hook on `origin::Consumer` (or wherever prefix - announcements live) that yields each broadcast under the prefix as it - appears and a `track::Consumer` for every track its producer creates. - SUBSCRIBE_TRACKS uses the same hook. - -Guidance: - -- A broadcast is a path and its epoch: a new epoch at the same path is a new - broadcast, and each delivered track names which one it belongs to. -- A track announced here is the same cached track an ordinary SUBSCRIBE gets: - a relay never holds two copies, and a track already subscribed upstream - joins the request rather than duplicating it. Overlapping prefixes from - different downstreams share upstream state where they overlap. -- Subscription parameters (priority, order, max delay, and later ranges) - apply to every track. The one per-track need is a start floor: the - [producer](/quest/m1/archive/producer.md) resumes each track at its last - stored frame by subscribing to the yielded `track::Consumer` with its own - floor, which aggregates like any other subscriber on that cached track. - Carry that floor upstream the way an ordinary SUBSCRIBE joining the track - would, rather than a per-track field in the request; test a resumed track - starting mid-group through a relay. -- Name how a track's end, a broadcast's end, and a refused track surface, and - how SUBSCRIBE_DROP applies per track. -- Wire: the request names the prefix; the reply announces broadcasts and - tracks with identifiers that group streams reference. Update - `drafts/draft-lcurley-moq-lite.md` and its lite-07 changelog, then run - `just drafts check` and `just test interop --all`. -- Auth: the request needs subscribe permission on the prefix, as - announcement discovery does; a broadcast outside the grant is never - delivered. -- Fan-out: benchmark downstream requests against broadcast and track count per - the AGENTS.md fan-out rule. -- Split the JS half into its own quest if the PR grows too large to review. - -Test on mock time, directly and through a relay: tracks present at the -request, a track added later (its first group arrives), a broadcast appearing -and ending under the prefix, a new epoch at the same path, a dynamic-only track -never created, and two downstream requests sharing one upstream request. - -Public API: the new prefix hook in Rust and `@moq/net`, mirrored names. Wire: -a new lite-07-wip request. - -## Related - -- [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - the subscription shape whose parameters this applies to every track diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 56e8fade427..8780e1ec244 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -1,41 +1,90 @@ -# [L] SUBSCRIBE_TRACKS and PUBLISH +# [L] SUBSCRIBE_TRACKS ## Goal -moq-net answers moq-transport SUBSCRIBE_TRACKS (draft-18 on) by sending a -PUBLISH for every track of every broadcast under its namespace prefix, and -accepts an upstream PUBLISH as a track pushed into the model, both through the -SUBSCRIBE_ALL hook. Today both are refused with NOT_SUPPORTED, since moq-net is -pull-only: `ietf/publisher.rs` refuses SUBSCRIBE_TRACKS and `ietf/subscriber.rs` -declines PUBLISH. +moq-net speaks moq-transport SUBSCRIBE_TRACKS (draft-18 on) in both +directions through the [TRACKS](/quest/m1/archive/tracks.md) model hook, +instead of refusing it with NOT_SUPPORTED: + +- **Inbound.** A moq-transport subscriber's SUBSCRIBE_TRACKS gets a PUBLISH + for every track of every broadcast under its namespace prefix, including + later tracks and broadcasts, each served from the cache like a SUBSCRIBE. +- **Outbound.** A lite TRACKS whose broadcast is served by a moq-transport + peer learns that peer's tracks by sending SUBSCRIBE_TRACKS with FORWARD=0 + and reading the PUBLISH each track produces, without its objects. Each + listed track is then SUBSCRIBEd like any other. + +An unsolicited inbound PUBLISH stays refused: moq-net never merges a pushed +track into a pulled copy. ## Plan -Decided by the maintainer on 2026-10-17: this belongs to the archive line and -maps 1:1 onto the prefix-scoped -[SUBSCRIBE_ALL](/quest/m1/archive/subscribe-all.md), so IETF clients and -moq-lite share one mechanism; moq-transport adapts to the moq-lite model, not -the reverse. Rejected: one SUBSCRIBE_ALL per announced broadcast under the -prefix, and exact-namespace only. +Decided by the maintainer on 2026-10-19 with TRACKS (see its Plan for the +quotes): "we don't need an extension; we could issue forward=0 to learn about +all tracks from IETF peers". This replaces the 2026-10-17 plan, which mapped +SUBSCRIBE_TRACKS 1:1 onto SUBSCRIBE_ALL and accepted inbound PUBLISH as a +pushed track. Accepting PUBLISH is dropped: it is the push-into-pull merge the +maintainer declined. + +FORWARD=0, verified against the draft-18 and draft-22 text (draft-22 sections +3.1.1, 3.6, 9.8 and 9.20.18): each PUBLISH a SUBSCRIBE_TRACKS produces starts its +subscription paused, carrying the full track name, Track Properties, and +LARGEST_OBJECT, and no objects flow until the subscriber resumes it. Gaps to +handle: + +- **Each PUBLISH is a subscription.** It holds a bidi stream and a request ID + at both ends. Recommended: answer each with an error (UNINTERESTED) once + the track is recorded, then SUBSCRIBE through the existing path, so a + listed track holds no state upstream beyond the listing and joins from the + cache like a lite one. Resuming the paused PUBLISH instead starts at the + live edge (draft-22 section 9.8), missing the first groups. Both are legal: + multiple subscriptions to one track are allowed, and rejecting a PUBLISH + leaves the SUBSCRIBE_TRACKS open. +- **Prefix, not broadcast.** SUBSCRIBE_TRACKS matches every namespace under + the prefix, so a listing of one broadcast filters to its exact namespace. + A session may hold only one SUBSCRIBE_TRACKS per overlapping prefix + (PREFIX_OVERLAP), so lite TRACKS for a namespace and one nested under it + share one upstream request. +- **Push inventory only.** An IETF relay lists the tracks it has been + PUBLISHed or already holds, not what an upstream would serve on SUBSCRIBE. + A track served only on request is unlisted, which matches TRACKS' rule. +- **No retraction message.** A track's end arrives as PUBLISH_DONE on its + paused subscription, which the recommended rejection gives up. A listed + IETF track is retracted when its own SUBSCRIBE ends, or never; say which in + the PR. +- **Properties.** TRACK_INFO comes from PUBLISH's Track Properties the way it + comes from SUBSCRIBE_OK today; a property TRACK_INFO needs but PUBLISH lacks + takes the same default. +- **A non-conforming peer** may send objects despite FORWARD=0; stop reading + them rather than buffer. + +Untrusted input: + +- Inbound, a hostile subscriber's SUBSCRIBE_TRACKS makes us open a PUBLISH + per track. Each holds a subscription slot; past the cap, send + PUBLISH_SKIPPED (draft-20 on) rather than hold more. Drafts 18 and 19 have + no skip signal, so there the track is simply not published. +- Outbound, a hostile publisher can PUBLISH without bound. Each track it + lists counts against the session's `announces` cap, as on lite, and the + stream and request-ID credit we grant bounds what is in flight. Guidance: - TRACK_PROPERTY_FILTER and any MUST that adds cost without a behavioral benefit may be refused or skipped; say which in the PR. -- Inbound PUBLISH maps a pushed track onto the receiving broadcast as an - ordinary track, so a relay serves it exactly as one it subscribed to. - Cover drafts 18 and later, plus `just test interop --all` and the IETF - passthrough suite where it exercises these messages. + passthrough suite where it exercises these messages. Update + `doc/concept/standard.md`, which lists SUBSCRIBE_TRACKS as refused. Test on mock time through a relay: a moq-transport subscriber's SUBSCRIBE_TRACKS receives a PUBLISH per track, including a later track and a -later broadcast under the prefix; a lite SUBSCRIBE_ALL and an IETF -SUBSCRIBE_TRACKS share one upstream request; an inbound PUBLISH reaches a lite -subscriber. +later broadcast under the prefix; a lite TRACKS over a moq-transport upstream +lists its tracks with FORWARD=0 and its SUBSCRIBE gets a new track's first +group; an unsolicited PUBLISH is refused. -Public API: none beyond SUBSCRIBE_ALL's hook. Wire: moq-transport support for -two existing messages. +Public API: none beyond the TRACKS hook. Wire: moq-transport support for an +existing message, and PUBLISH answered with an error. ## Required -- [SUBSCRIBE_ALL](/quest/m1/archive/subscribe-all.md) - the model hook this maps onto +- [TRACKS](/quest/m1/archive/tracks.md) - the model hook this maps onto diff --git a/quest/m1/archive/tracks.md b/quest/m1/archive/tracks.md new file mode 100644 index 00000000000..ec9d0a2638c --- /dev/null +++ b/quest/m1/archive/tracks.md @@ -0,0 +1,119 @@ +# [L] TRACKS + +## Goal + +A moq-lite-07 TRACKS request lists the tracks of one broadcast as its +producer creates them, each with its name and TRACK_INFO, and retracts a +track that ends. The subscriber then SUBSCRIBEs to each track it wants, so +delivery stays pull: nothing is ever pushed into a pulled copy. A listing +never creates a track the publisher serves only on request (an any-name +`broadcast::Dynamic` handler such as a transcoder). A relay answers many +downstream TRACKS for a broadcast from one upstream TRACKS. Rust and JS both +speak it. + +## Plan + +Decided by the maintainer on 2026-10-19, replacing SUBSCRIBE_ALL. Asked about +listing tracks against SUBSCRIBE_ALL: "ugh I guess the difference is push vs +pull. Maybe the right answer here is actually a method to list all of the +tracks within a broadcast/namespace. I'd rather do that for moq-lite than +deal with merging subscriptions and publishes." Choosing between a `.tracks` +track, a lite TRACKS message, and keeping SUBSCRIBE_ALL: "I think TRACKS, +which would actually be kind of nice as an IETF extension too? Oh actually we +don't need an extension; we could issue forward=0 to learn about all tracks +from IETF peers?" + +Why the 2026-10-17 rejection of "a track-listing stream plus a SUBSCRIBE per +track" no longer holds: + +- **The first-groups race is closed by the cache.** A SUBSCRIBE with no floor + and a max age at least the publisher's starts at the oldest group the cache + still holds, so a track's first groups reach a subscriber that learned of + the track an RTT late, as long as the publisher retains them (Publisher Max + Age). A group already past retention is a gap, which the archive records + anyway. +- **One mechanism cost more than two.** + [moq-dev/moq#5931](https://github.com/moq-dev/moq/pull/5931) implemented + SUBSCRIBE_ALL and found that a relay has to merge pushed feeds into pulled + copies: copies shared by path and epoch, a feed attached to a track already + requested, bounded SUBSCRIBEs for floors below the feed, aggregate + preferences, and a choice between duplicate copies and splicing an + epochless restart into an old copy. A listing plus ordinary SUBSCRIBEs + needs none of that. + +Scope is one broadcast, decided here with ANNOUNCE in mind. ANNOUNCE already +discovers broadcasts under a prefix, with their epochs and restarts; a +prefix-scoped TRACKS would duplicate that and need every route handler to +answer for a whole prefix (#5931's `Lister`). A prefix consumer composes the +two: ANNOUNCE for broadcasts, TRACKS per announced broadcast. TRACKS names a +broadcast path and an Epoch and resolves like TRACK and SUBSCRIBE, so the +listing is pinned to the instance that answers it and ends with that +instance, never spliced into the next one. Rejected: a prefix-scoped TRACKS, +and a `.tracks` track (a track carrying a listing reuses delivery but mixes +control into the media namespace and needs its own retraction encoding). + +Costs, accepted: + +- **An RTT per new track** before its first group, on top of the listing's + one-way delay. The cache absorbs it while groups are retained. +- **A subscription per track.** Each SUBSCRIBE takes a slot in the session's + subscription cap (`session.subscriptions`, 1000 by default in + `rs/moq-relay/src/limits.rs`; 10,000 for peers), so an archive of more than + about a thousand tracks per session hits it. The cap does not change: it + measures per-track serving state a relay holds either way, and + SUBSCRIBE_ALL only hid that cost behind one slot per request. A large + archive shards its prefix across sessions, or its operator raises the + existing setting for that deployment. No new constant. + +Guidance: + +- Wire: a new lite-07-wip bidi stream. The request names the broadcast path + and Epoch. The reply announces each track (name and TRACK_INFO fields) with + an implicit ID and retracts it by ID when the track ends, so a late joiner + gets the current set and a churning publisher costs bytes, not held state. + Reuse the Announce stream's conventions (implicit IDs, an initial-set count) + where they fit. The stream ends when the broadcast instance does. Update + `drafts/draft-lcurley-moq-lite.md` and its lite-07 changelog, then run + `just drafts check` and `just test interop --all`. Older versions refuse the + stream type; no emulation over SUBSCRIBE. +- Model: a broadcast published locally lists the tracks its producer holds. + A broadcast served on request (`broadcast::Dynamic`, or a relay's copy of an + upstream broadcast) answers through its handler; a session forwards the + request upstream, and a handler that does not answer lists nothing. A relay + never answers from its own track table, which holds only what was + subscribed. The listing hangs off the broadcast copy a SUBSCRIBE already + resolves, so it needs no new copy registry. #5931's + `broadcast::Consumer::tracks()` is a starting point. +- Auth: TRACKS needs subscribe permission on the broadcast path, as TRACK + does. Listing exposes names, not access; each SUBSCRIBE is authorized as + usual. +- Untrusted input: + - A hostile publisher can list without bound. Each listed track it makes a + relay hold counts against that session's existing `announces` cap, the + budget for discovery state a peer pushes at us; past it, the relay resets + the listing rather than holding more. Names keep the existing length caps. + - A hostile subscriber can open many TRACKS. Each takes a subscription slot, + downstream listings of one broadcast share one upstream TRACKS, and a + downstream reader is a cursor into the shared listing, so a slow or stalled + reader costs flow control, not a copy of the listing. +- Fan-out: benchmark downstream listings against tracks per broadcast and + broadcasts per relay, per the AGENTS.md fan-out rule; a new track's cost + must not grow with the broadcast count. +- Split the JS half into its own quest if the PR grows too large to review. + +Test on mock time, directly and through a relay: tracks present at the +request, a track added later (a SUBSCRIBE from the listing gets its first +group from the cache), a track ending and being retracted, the broadcast +ending the listing, a new epoch at the same path not continuing an old +listing, a dynamic-only track never created, two downstream listings sharing +one upstream TRACKS, and the `announces` cap resetting an oversized listing. + +Public API: a track-listing hook on `broadcast::Consumer` in Rust and +`@moq/net`, mirrored names, and a way for a `broadcast::Dynamic` handler to +answer it. Wire: a new lite-07-wip stream. + +## Related + +- [SUBSCRIBE_TRACKS](/quest/m1/archive/subscribe-tracks.md) - moq-transport answers and learns listings through this hook +- [SUBSCRIBE_OK names where delivery starts](/quest/m1/subscribe-ok-start.md) - a warm relay answers like a cold one, found while building SUBSCRIBE_ALL +- [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - the subscription shape each per-track SUBSCRIBE uses diff --git a/quest/m1/lite07-finalize.md b/quest/m1/lite07-finalize.md index cafd9f22258..859f82dde17 100644 --- a/quest/m1/lite07-finalize.md +++ b/quest/m1/lite07-finalize.md @@ -62,8 +62,10 @@ The cut: rejection. If SETUP keeps its own rule, the lite-07 changelog still names that rejection, not only the cap. -Decided 2026-10-17: SUBSCRIBE_ALL joins Required, so lite-07 ships the -prefix-scoped request the archive line and IETF SUBSCRIBE_TRACKS mapping need. +Decided 2026-10-17: the archive line's discovery request joins Required, so +lite-07 ships what the archive and the IETF SUBSCRIBE_TRACKS mapping need. +On 2026-10-19 TRACKS, a per-broadcast track listing, replaced SUBSCRIBE_ALL +and kept that slot. The `Time(pts)` range end rides in Subscribe ranges, already Required. Rust's lite-07 varints already carry the full 64 bits the draft specifies, @@ -88,7 +90,7 @@ is published; older versions are unchanged. - [Refresh on the lite wire](/quest/m1/refresh/lite.md) - TRACK_INFO carries the refresh ratio and SUBSCRIBE_UPDATE the wanted group - [Compressed tracks in Rust](/quest/m1/compress/core.md) - TRACK_INFO carries the compress flag and the frame header each compressed frame's inflated size - [Compressed tracks in js/net](/quest/m1/compress/js.md) - JS reads and writes compressed lite-07 tracks instead of refusing them -- [SUBSCRIBE_ALL](/quest/m1/archive/subscribe-all.md) - lite-07 carries the prefix-scoped request for every track of every broadcast under a path +- [TRACKS](/quest/m1/archive/tracks.md) - lite-07 carries the request listing a broadcast's tracks as they appear ## Related diff --git a/quest/m1/subscribe-ok-start.md b/quest/m1/subscribe-ok-start.md new file mode 100644 index 00000000000..fcb998fb77e --- /dev/null +++ b/quest/m1/subscribe-ok-start.md @@ -0,0 +1,34 @@ +# [S] SUBSCRIBE_OK names where delivery starts + +## Goal + +SUBSCRIBE_OK's `Group` is the first group the publisher actually delivers, +as the lite draft defines it, so a warm relay and a cold one answer the same +subscription the same way. Today a publisher whose read cursor gives up +cached groups as stale before the start resolves still names the cache's +oldest group, claiming groups it never sends. Only a group skipped for a +missing head is excluded now. + +## Plan + +Found while building SUBSCRIBE_ALL +([moq-dev/moq#5931](https://github.com/moq-dev/moq/pull/5931), commit +`f074d09e7`), which fixed it in `TrackRun` (`rs/moq-net/src/lite/publisher.rs`) +and covered it with `the_start_names_past_the_stale_groups_it_skips`. That +PR is being replaced, so port the fix and its test on their own: record +every range the cursor gives up before the start resolves (a missing head, +stale, or dropped upstream), and resolve the start past them. Check whether +the moq-transport publisher's SUBSCRIBE_OK and the JS publisher have the same +gap, and fix them here if so. + +Add a relay test on mock time: the same SUBSCRIBE through a cold relay and a +warm one whose cache holds groups older than the subscriber's max age gets +the same `Group`. + +Public API: none. Wire: no encoding change; SUBSCRIBE_OK's `Group` changes +behavior on every lite version with a resolved start (lite-05 on) to match +its definition. + +## Related + +- [TRACKS](/quest/m1/archive/tracks.md) - the line of work that surfaced it From 712527708438cafe6cb3d2da9a7349bed44566f9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 12:42:34 -0700 Subject: [PATCH 02/20] quest(m1): TRACKS carries TRACK_INFO, caught-up, and removal events Records the maintainer's 2026-10-19 decisions: one broadcast per TRACKS, TRACKS gates the lite-07 cut, and the listing reuses TRACK_INFO, marks the initial set like ANNOUNCE_OK's Active Count, and names removed tracks. SUBSCRIBE_TRACKS maps each event, holding a FORWARD=0 PUBLISH paused so PUBLISH_DONE can stand in for removal. Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/README.md | 2 +- quest/m1/archive/subscribe-tracks.md | 61 +++++++++++++++++----------- quest/m1/archive/tracks.md | 61 +++++++++++++++++++++++----- 3 files changed, 88 insertions(+), 36 deletions(-) diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 920638e8c81..3ea3593c774 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -79,7 +79,7 @@ from a host. Each child fixes the stale docs it touches. ## Required -- [TRACKS](/quest/m1/archive/tracks.md) - a moq-lite-07 request lists a broadcast's tracks with their TRACK_INFO as they appear, for the subscriber to SUBSCRIBE per track, and relays fan it out from one upstream request +- [TRACKS](/quest/m1/archive/tracks.md) - a moq-lite-07 request lists a broadcast's tracks with their TRACK_INFO, marks when it has caught up, and names removed tracks, for the subscriber to SUBSCRIBE per track, and relays fan it out from one upstream request - [Archive producer](/quest/m1/archive/producer.md) - `archive::Producer` stores every matching broadcast as duration-cut per-track objects keyed by group and pts, recording gaps instead of stalling, with an optional DVR mode - [Archive consumer](/quest/m1/archive/consumer.md) - `archive::Consumer` serves a stored prefix as a broadcast from its key listing, and `moq export/import archive` use the new pair - [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the root `archive` section is gone; an opt-in per-rendition `hls` field marks each aligned HLS segment's first group and pts and declares its nominal duration diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 8780e1ec244..5605617fe0f 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -15,7 +15,8 @@ instead of refusing it with NOT_SUPPORTED: listed track is then SUBSCRIBEd like any other. An unsolicited inbound PUBLISH stays refused: moq-net never merges a pushed -track into a pulled copy. +track into a pulled copy. A FORWARD=0 PUBLISH answering our own +SUBSCRIBE_TRACKS carries no data, so holding it is not that merge. ## Plan @@ -29,17 +30,30 @@ maintainer declined. FORWARD=0, verified against the draft-18 and draft-22 text (draft-22 sections 3.1.1, 3.6, 9.8 and 9.20.18): each PUBLISH a SUBSCRIBE_TRACKS produces starts its subscription paused, carrying the full track name, Track Properties, and -LARGEST_OBJECT, and no objects flow until the subscriber resumes it. Gaps to -handle: - -- **Each PUBLISH is a subscription.** It holds a bidi stream and a request ID - at both ends. Recommended: answer each with an error (UNINTERESTED) once - the track is recorded, then SUBSCRIBE through the existing path, so a - listed track holds no state upstream beyond the listing and joins from the - cache like a lite one. Resuming the paused PUBLISH instead starts at the - live edge (draft-22 section 9.8), missing the first groups. Both are legal: - multiple subscriptions to one track are allowed, and rejecting a PUBLISH - leaves the SUBSCRIBE_TRACKS open. +LARGEST_OBJECT, and no objects flow until the subscriber resumes it. + +TRACKS' three events (see its Plan) map as follows: + +| TRACKS event | Inbound: we answer SUBSCRIBE_TRACKS | Outbound: we send it with FORWARD=0 | +|---|---|---| +| Track added, with TRACK_INFO | a PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today | a PUBLISH; TRACK_INFO from its Track Properties | +| Caught up | nothing to send: moq-transport has no marker | gap: SUBSCRIBE_TRACKS_OK is a bare REQUEST_OK, and PUBLISHes arrive on their own streams in no order | +| Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH | PUBLISH_DONE on the paused PUBLISH | + +- **Caught up has no IETF equivalent.** Outbound, report what is known when + SUBSCRIBE_TRACKS_OK arrives (usually nothing), as relays report current + state, and every later PUBLISH is a live add. A lister over a moq-transport + upstream therefore cannot tell "no tracks" from "not yet"; document it. +- **Removal needs the PUBLISH kept.** PUBLISH_DONE is the only removal + signal, so an outbound PUBLISH is accepted paused (PUBLISH_OK, FORWARD=0) + and held as the listing entry, while data flows through a separate + SUBSCRIBE from the existing path, which joins from the cache like a lite + one. Multiple subscriptions to one track are allowed. Resuming the paused + PUBLISH instead starts at the live edge (draft-22 section 9.8), missing the + first groups. TRACK_ENDED means the track is gone; any other status + (GOING_AWAY, EXCESSIVE_LOAD) also ends what we know of it, so it removes the + entry too, and a later PUBLISH for the name is a new add. The cost is a + paused subscription per listed track at both ends, bounded below. - **Prefix, not broadcast.** SUBSCRIBE_TRACKS matches every namespace under the prefix, so a listing of one broadcast filters to its exact namespace. A session may hold only one SUBSCRIBE_TRACKS per overlapping prefix @@ -48,13 +62,8 @@ handle: - **Push inventory only.** An IETF relay lists the tracks it has been PUBLISHed or already holds, not what an upstream would serve on SUBSCRIBE. A track served only on request is unlisted, which matches TRACKS' rule. -- **No retraction message.** A track's end arrives as PUBLISH_DONE on its - paused subscription, which the recommended rejection gives up. A listed - IETF track is retracted when its own SUBSCRIBE ends, or never; say which in - the PR. -- **Properties.** TRACK_INFO comes from PUBLISH's Track Properties the way it - comes from SUBSCRIBE_OK today; a property TRACK_INFO needs but PUBLISH lacks - takes the same default. +- **Properties.** A property TRACK_INFO needs but PUBLISH lacks takes the + same default it takes from SUBSCRIBE_OK today. - **A non-conforming peer** may send objects despite FORWARD=0; stop reading them rather than buffer. @@ -64,9 +73,12 @@ Untrusted input: per track. Each holds a subscription slot; past the cap, send PUBLISH_SKIPPED (draft-20 on) rather than hold more. Drafts 18 and 19 have no skip signal, so there the track is simply not published. -- Outbound, a hostile publisher can PUBLISH without bound. Each track it - lists counts against the session's `announces` cap, as on lite, and the +- Outbound, a hostile publisher can PUBLISH without bound. Each held paused + PUBLISH counts against the session's `announces` cap, as a lite listed track + does; past it, refuse the PUBLISH (EXCESSIVE_LOAD) rather than hold more. The stream and request-ID credit we grant bounds what is in flight. +- Either way, PUBLISH and PUBLISH_DONE churn costs a stream per add and holds + only the live set, as on lite. Guidance: @@ -79,11 +91,12 @@ Guidance: Test on mock time through a relay: a moq-transport subscriber's SUBSCRIBE_TRACKS receives a PUBLISH per track, including a later track and a later broadcast under the prefix; a lite TRACKS over a moq-transport upstream -lists its tracks with FORWARD=0 and its SUBSCRIBE gets a new track's first -group; an unsolicited PUBLISH is refused. +lists its tracks with FORWARD=0, its SUBSCRIBE gets a new track's first +group, and a PUBLISH_DONE removes the entry; a moq-transport subscriber gets +PUBLISH_DONE when a lite track is removed; an unsolicited PUBLISH is refused. Public API: none beyond the TRACKS hook. Wire: moq-transport support for an -existing message, and PUBLISH answered with an error. +existing message, and a FORWARD=0 PUBLISH held paused as a listing entry. ## Required diff --git a/quest/m1/archive/tracks.md b/quest/m1/archive/tracks.md index ec9d0a2638c..e136b35cf57 100644 --- a/quest/m1/archive/tracks.md +++ b/quest/m1/archive/tracks.md @@ -3,8 +3,9 @@ ## Goal A moq-lite-07 TRACKS request lists the tracks of one broadcast as its -producer creates them, each with its name and TRACK_INFO, and retracts a -track that ends. The subscriber then SUBSCRIBEs to each track it wants, so +producer creates them, each with its name and TRACK_INFO, marks when the +lister has caught up with every track that existed when it asked, and names +each track that no longer exists. The subscriber then SUBSCRIBEs to each track it wants, so delivery stays pull: nothing is ever pushed into a pulled copy. A listing never creates a track the publisher serves only on request (an any-name `broadcast::Dynamic` handler such as a transcoder). A relay answers many @@ -41,7 +42,7 @@ track" no longer holds: epochless restart into an old copy. A listing plus ordinary SUBSCRIBEs needs none of that. -Scope is one broadcast, decided here with ANNOUNCE in mind. ANNOUNCE already +Scope is one broadcast, decided by the maintainer on 2026-10-19. ANNOUNCE already discovers broadcasts under a prefix, with their epochs and restarts; a prefix-scoped TRACKS would duplicate that and need every route handler to answer for a whole prefix (#5931's `Lister`). A prefix consumer composes the @@ -52,6 +53,37 @@ instance, never spliced into the next one. Rejected: a prefix-scoped TRACKS, and a `.tracks` track (a track carrying a listing reuses delivery but mixes control into the media namespace and needs its own retraction encoding). +Decided by the maintainer on 2026-10-19: TRACKS still gates the lite-07 cut +([Finalize moq-lite-07](/quest/m1/lite07-finalize.md)), so the archive line +has its discovery on the published wire. + +Listing events, decided by the maintainer on 2026-10-19 ("for TRACKS, you +should report the TRACK_INFO as well for each track (maybe reuse the message +entirely?) as well as have an event for knowing when we're caught up, and +when a track no longer exists"): + +- **Track added, with its TRACK_INFO.** Reuse the TRACK_INFO message + encoding wholesale: the entry is the track name followed by an embedded, + length-prefixed TRACK_INFO, so one decoder serves both streams and any + field TRACK_INFO gains (refresh, compress) reaches listings without a second + wire change. It fits because TRACK_INFO holds only fixed per-track + properties and no name or stream-specific field. What differs is only what + wraps it: the name, and an implicit ID assigned per entry, as ANNOUNCE_START + assigns Announce IDs. A lister MAY skip the Track Stream for a track it + learned this way, since the properties are the same immutable ones. +- **Caught up.** Mirror ANNOUNCE_OK's `Active Count`: the reply opens with a + count of the tracks in the initial set, sent first, so the lister knows when + it holds every track that existed when it asked; entries past the count are + live. A relay answers with the count its one resolved upstream reported, as + a SUBSCRIBE waits for its own upstream's SUBSCRIBE_OK, and a relay already + holding a caught-up upstream listing answers at once from it. It never + waits on any node beyond that route. +- **Track removed.** A named event referencing the entry's ID, sent when the + publisher no longer serves the name (it left the broadcast's track table), + so a SUBSCRIBE for it would now be refused. A track created again under the + name gets a new entry and ID. Removing an ID that is not live is a protocol + violation, as on the Announce stream. + Costs, accepted: - **An RTT per new track** before its first group, on top of the listing's @@ -67,12 +99,9 @@ Costs, accepted: Guidance: -- Wire: a new lite-07-wip bidi stream. The request names the broadcast path - and Epoch. The reply announces each track (name and TRACK_INFO fields) with - an implicit ID and retracts it by ID when the track ends, so a late joiner - gets the current set and a churning publisher costs bytes, not held state. - Reuse the Announce stream's conventions (implicit IDs, an initial-set count) - where they fit. The stream ends when the broadcast instance does. Update +- Wire: a new lite-07-wip bidi stream carrying the three events above. The + request names the broadcast path and Epoch. The stream ends when the + broadcast instance does. Update `drafts/draft-lcurley-moq-lite.md` and its lite-07 changelog, then run `just drafts check` and `just test interop --all`. Older versions refuse the stream type; no emulation over SUBSCRIBE. @@ -92,6 +121,13 @@ Guidance: relay hold counts against that session's existing `announces` cap, the budget for discovery state a peer pushes at us; past it, the relay resets the listing rather than holding more. Names keep the existing length caps. + - A hostile publisher can churn, adding and removing tracks as fast as it + can send. Held state is the live set, never the history, and a + downstream cursor reads the current set rather than a log: an add and its + removal that a slow reader has not reached collapse to nothing, so churn + costs a relay per-event work across its listings of that broadcast (the + same fan-out an Announce stream pays) but no backlog. Implicit IDs keep + growing, which costs a varint byte now and then, not state. - A hostile subscriber can open many TRACKS. Each takes a subscription slot, downstream listings of one broadcast share one upstream TRACKS, and a downstream reader is a cursor into the shared listing, so a slow or stalled @@ -102,8 +138,11 @@ Guidance: - Split the JS half into its own quest if the PR grows too large to review. Test on mock time, directly and through a relay: tracks present at the -request, a track added later (a SUBSCRIBE from the listing gets its first -group from the cache), a track ending and being retracted, the broadcast +request and the caught-up count matching them, a track added later (a +SUBSCRIBE from the listing gets its first group from the cache), a track +removed and created again under its name (a new ID), add and remove churn +collapsing for a stalled reader, a removal of an unknown ID refused, the +broadcast ending the listing, a new epoch at the same path not continuing an old listing, a dynamic-only track never created, two downstream listings sharing one upstream TRACKS, and the `announces` cap resetting an oversized listing. From 3958a1704aa025a8dce4947b6b42165e998c135d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 12:51:32 -0700 Subject: [PATCH 03/20] quest(m1): TRACKS caught-up is hop-local A relay answers caught up at once with what it holds and never waits on its upstream; upstream tracks arrive as live adds. Decided by the maintainer 2026-10-19. Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/subscribe-tracks.md | 11 ++++++----- quest/m1/archive/tracks.md | 14 ++++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 5605617fe0f..d8d00f7f244 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -37,13 +37,14 @@ TRACKS' three events (see its Plan) map as follows: | TRACKS event | Inbound: we answer SUBSCRIBE_TRACKS | Outbound: we send it with FORWARD=0 | |---|---|---| | Track added, with TRACK_INFO | a PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today | a PUBLISH; TRACK_INFO from its Track Properties | -| Caught up | nothing to send: moq-transport has no marker | gap: SUBSCRIBE_TRACKS_OK is a bare REQUEST_OK, and PUBLISHes arrive on their own streams in no order | +| Caught up | nothing to send: moq-transport has no marker | not needed: the lite side reports this hop's state at once, and PUBLISHes arrive as live adds | | Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH | PUBLISH_DONE on the paused PUBLISH | -- **Caught up has no IETF equivalent.** Outbound, report what is known when - SUBSCRIBE_TRACKS_OK arrives (usually nothing), as relays report current - state, and every later PUBLISH is a live add. A lister over a moq-transport - upstream therefore cannot tell "no tracks" from "not yet"; document it. +- **Caught up has no IETF equivalent, and needs none.** Caught up means + caught up with this hop (see TRACKS), so a relay answers a lite TRACKS at + once with what it holds, whatever its upstream speaks, and every PUBLISH the + FORWARD=0 SUBSCRIBE_TRACKS brings is a live add. Inbound, nothing stands in + for it. - **Removal needs the PUBLISH kept.** PUBLISH_DONE is the only removal signal, so an outbound PUBLISH is accepted paused (PUBLISH_OK, FORWARD=0) and held as the listing entry, while data flows through a separate diff --git a/quest/m1/archive/tracks.md b/quest/m1/archive/tracks.md index e136b35cf57..2451eed540a 100644 --- a/quest/m1/archive/tracks.md +++ b/quest/m1/archive/tracks.md @@ -73,11 +73,11 @@ when a track no longer exists"): learned this way, since the properties are the same immutable ones. - **Caught up.** Mirror ANNOUNCE_OK's `Active Count`: the reply opens with a count of the tracks in the initial set, sent first, so the lister knows when - it holds every track that existed when it asked; entries past the count are - live. A relay answers with the count its one resolved upstream reported, as - a SUBSCRIBE waits for its own upstream's SUBSCRIBE_OK, and a relay already - holding a caught-up upstream listing answers at once from it. It never - waits on any node beyond that route. + it holds every track this hop knew when it asked; entries past the count are + live. Caught up means caught up with this hop, decided by the maintainer on + 2026-10-19: a relay answers at once with what it currently holds and never + waits on its upstream, since relays never block on peers. A cold relay may + report zero; the upstream's tracks then arrive as live adds. - **Track removed.** A named event referencing the entry's ID, sent when the publisher no longer serves the name (it left the broadcast's track table), so a SUBSCRIBE for it would now be refused. A track created again under the @@ -138,7 +138,9 @@ Guidance: - Split the JS half into its own quest if the PR grows too large to review. Test on mock time, directly and through a relay: tracks present at the -request and the caught-up count matching them, a track added later (a +request and the caught-up count matching them, a cold relay answering +caught up at once with what it holds and the upstream's tracks following as +live adds, a track added later (a SUBSCRIBE from the listing gets its first group from the cache), a track removed and created again under its name (a new ID), add and remove churn collapsing for a stalled reader, a removal of an unknown ID refused, the From 0d9e592c21ded0d1c4d3f1b0504f0cbdb1df8429 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 12:54:06 -0700 Subject: [PATCH 04/20] quest(m1): TRACKS signals caught-up in order; only TRACK_ENDED removes A count sent first breaks when an initial track is removed before a slow reader reaches it, so CAUGHT_UP is an ordered event and IDs are assigned per emitted add. A non-TRACK_ENDED PUBLISH_DONE fails the listing instead of hiding a live track. Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/subscribe-tracks.md | 17 +++++++++++------ quest/m1/archive/tracks.md | 24 ++++++++++++++++-------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index d8d00f7f244..0c29659bb8e 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -37,8 +37,8 @@ TRACKS' three events (see its Plan) map as follows: | TRACKS event | Inbound: we answer SUBSCRIBE_TRACKS | Outbound: we send it with FORWARD=0 | |---|---|---| | Track added, with TRACK_INFO | a PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today | a PUBLISH; TRACK_INFO from its Track Properties | -| Caught up | nothing to send: moq-transport has no marker | not needed: the lite side reports this hop's state at once, and PUBLISHes arrive as live adds | -| Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH | PUBLISH_DONE on the paused PUBLISH | +| Caught up | nothing to send: moq-transport has no marker | not needed: the lite side sends CAUGHT_UP for this hop at once, and PUBLISHes arrive as live adds | +| Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH | PUBLISH_DONE with TRACK_ENDED on the paused PUBLISH; any other status fails the listing | - **Caught up has no IETF equivalent, and needs none.** Caught up means caught up with this hop (see TRACKS), so a relay answers a lite TRACKS at @@ -51,9 +51,13 @@ TRACKS' three events (see its Plan) map as follows: SUBSCRIBE from the existing path, which joins from the cache like a lite one. Multiple subscriptions to one track are allowed. Resuming the paused PUBLISH instead starts at the live edge (draft-22 section 9.8), missing the - first groups. TRACK_ENDED means the track is gone; any other status - (GOING_AWAY, EXCESSIVE_LOAD) also ends what we know of it, so it removes the - entry too, and a later PUBLISH for the name is a new add. The cost is a + first groups. Only TRACK_ENDED ("no longer being published") is a removal, + and a later PUBLISH for the name is a new add. Any other status + (GOING_AWAY, EXCESSIVE_LOAD, ...) ends only that subscription while the + track may still be published, and the peer need not PUBLISH it again, so it + fails the listing loudly: the lite TRACKS stream resets and the lister + requests again, which re-issues SUBSCRIBE_TRACKS. Never turn it into a + removal, which would hide a live track for good. The cost is a paused subscription per listed track at both ends, bounded below. - **Prefix, not broadcast.** SUBSCRIBE_TRACKS matches every namespace under the prefix, so a listing of one broadcast filters to its exact namespace. @@ -93,7 +97,8 @@ Test on mock time through a relay: a moq-transport subscriber's SUBSCRIBE_TRACKS receives a PUBLISH per track, including a later track and a later broadcast under the prefix; a lite TRACKS over a moq-transport upstream lists its tracks with FORWARD=0, its SUBSCRIBE gets a new track's first -group, and a PUBLISH_DONE removes the entry; a moq-transport subscriber gets +group, a PUBLISH_DONE with TRACK_ENDED removes the entry, and one with +another status resets the listing; a moq-transport subscriber gets PUBLISH_DONE when a lite track is removed; an unsolicited PUBLISH is refused. Public API: none beyond the TRACKS hook. Wire: moq-transport support for an diff --git a/quest/m1/archive/tracks.md b/quest/m1/archive/tracks.md index 2451eed540a..6249b1f87b4 100644 --- a/quest/m1/archive/tracks.md +++ b/quest/m1/archive/tracks.md @@ -71,10 +71,14 @@ when a track no longer exists"): wraps it: the name, and an implicit ID assigned per entry, as ANNOUNCE_START assigns Announce IDs. A lister MAY skip the Track Stream for a track it learned this way, since the properties are the same immutable ones. -- **Caught up.** Mirror ANNOUNCE_OK's `Active Count`: the reply opens with a - count of the tracks in the initial set, sent first, so the lister knows when - it holds every track this hop knew when it asked; entries past the count are - live. Caught up means caught up with this hop, decided by the maintainer on +- **Caught up.** An explicit CAUGHT_UP event, sent once, in order, when the + reader's cursor reaches what was the live head when it asked, so the lister + knows it holds every track this hop knew then; entries after it are live. + Not ANNOUNCE_OK's `Active Count`: a count sent first commits to that many + adds, so a track removed before a slow reader reaches its add would either + leave the count unmet forever or force the hop to retain every initial + entry per reader, which a stalled hostile reader pins. The event costs + about a byte more per stream and keeps each reader an O(1) cursor. Caught up means caught up with this hop, decided by the maintainer on 2026-10-19: a relay answers at once with what it currently holds and never waits on its upstream, since relays never block on peers. A cold relay may report zero; the upstream's tracks then arrive as live adds. @@ -124,9 +128,12 @@ Guidance: - A hostile publisher can churn, adding and removing tracks as fast as it can send. Held state is the live set, never the history, and a downstream cursor reads the current set rather than a log: an add and its - removal that a slow reader has not reached collapse to nothing, so churn + removal that a slow reader has not reached collapse to nothing, before or + after CAUGHT_UP, so churn costs a relay per-event work across its listings of that broadcast (the - same fan-out an Announce stream pays) but no backlog. Implicit IDs keep + same fan-out an Announce stream pays) but no backlog. Implicit IDs are + assigned per emitted add on each stream, never per source event, so a + collapsed pair consumes no ID and later removals stay in sync. They keep growing, which costs a varint byte now and then, not state. - A hostile subscriber can open many TRACKS. Each takes a subscription slot, downstream listings of one broadcast share one upstream TRACKS, and a @@ -138,12 +145,13 @@ Guidance: - Split the JS half into its own quest if the PR grows too large to review. Test on mock time, directly and through a relay: tracks present at the -request and the caught-up count matching them, a cold relay answering +request arriving before CAUGHT_UP, a cold relay answering caught up at once with what it holds and the upstream's tracks following as live adds, a track added later (a SUBSCRIBE from the listing gets its first group from the cache), a track removed and created again under its name (a new ID), add and remove churn -collapsing for a stalled reader, a removal of an unknown ID refused, the +collapsing for a stalled reader (including an initial track removed before +the reader reaches it, with CAUGHT_UP still arriving and later IDs in sync), a removal of an unknown ID refused, the broadcast ending the listing, a new epoch at the same path not continuing an old listing, a dynamic-only track never created, two downstream listings sharing From 4f0c31673f2341b27cbf1dc21c445921b05a9ea5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 13:20:21 -0700 Subject: [PATCH 05/20] quest(m1): TRACKS removes by name, CAUGHT_UP replaces Active Count No count field remains: TRACKS and lite-07 announces mark their initial set with one ordered CAUGHT_UP event. Removal names the track, so a hop keeps two positions per reader instead of per-reader IDs. A covering-prefix route is not inventory, so the archive records broadcasts announced at their own path. Capped SUBSCRIBE_TRACKS tracks are signaled with PUBLISH_BLOCKED or PUBLISH_SKIPPED. Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 + quest/m1/announce-caught-up.md | 49 +++++++++++++++++ quest/m1/archive/producer.md | 8 ++- quest/m1/archive/subscribe-tracks.md | 8 +-- quest/m1/archive/tracks.md | 78 ++++++++++++++++------------ quest/m1/lite07-finalize.md | 1 + 6 files changed, 108 insertions(+), 37 deletions(-) create mode 100644 quest/m1/announce-caught-up.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 4e806099887..21a1c52041d 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -66,6 +66,7 @@ lands before the cut. - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, flate, audio, and video namespaces built from the handle below - [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - every stream group in a lite subscription arrives or is dropped by name, and lite-07 drops its stream count for it - [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - a lite-07 SUBSCRIBE asks for past and live ranges with absolute or relative ends, in either order, and replaces FETCH; merged ranges never starve a subscriber; relays fill misses by range, including over moq-transport +- [Announce CAUGHT_UP](/quest/m1/announce-caught-up.md) - lite-07's Announce stream marks its initial set with the same ordered CAUGHT_UP as TRACKS, replacing `Active Count` - [More tests under load](/quest/m1/test-flakes-2/README.md) - the second round of load-only failures, one quest per flake, fixed at the cause - [A public model](/quest/m1/public-model/README.md) - every model item a built-in session reaches is public, unnecessary, or marked deliberately private, so a custom publisher, subscriber, or relay builds from the public model alone - [Lazy discovery](/quest/m1/lazy-discovery/README.md) - a `consume` session requests announcements only for prefixes the origin is watching, in JS then Rust; `follow`, `broadcasts`, and the `discovery` option go away diff --git a/quest/m1/announce-caught-up.md b/quest/m1/announce-caught-up.md new file mode 100644 index 00000000000..5ba86c82613 --- /dev/null +++ b/quest/m1/announce-caught-up.md @@ -0,0 +1,49 @@ +# [S] Announce CAUGHT_UP + +## Goal + +On lite-07, the Announce stream marks the end of its initial set with the +same ordered CAUGHT_UP event [TRACKS](/quest/m1/archive/tracks.md) uses, +replacing ANNOUNCE_OK's `Active Count`. A subscriber knows it holds every +route this hop knew when it asked, even when one of those routes ends before +a slow reader reaches it. Published versions keep `Active Count` unchanged. + +## Plan + +Decided by the maintainer on 2026-10-19 with TRACKS ("We don't need the +count at all"). A count sent first commits to that many ANNOUNCE_STARTs, so +an initial route that ends before a slow reader reaches its start leaves +the count unmet, unless the hop retains every initial announcement per +reader. CAUGHT_UP is sent once, in order, when the reader reaches what was +the live head when it asked, so an initial route that comes and goes before +then collapses like any other. Keep the event identical in shape to TRACKS' +CAUGHT_UP, so one encoding and one consumer rule serve both streams. + +- Caught up means caught up with this hop: a relay answers at once with what + it holds and never waits on its upstream (relays never block on peers). +- lite-07's ANNOUNCE_OK loses `Active Count`; no count field remains. Update + `drafts/draft-lcurley-moq-lite.md` (ANNOUNCE_OK, the Announce stream text, + and the lite-07 changelog), then run `just drafts check` and + `just test interop --all`. +- Rust and JS consumers that batch the initial set (such as `moq ls`) wait + for CAUGHT_UP on lite-07 and keep reading the count on older versions. +- Coordinate with [Routes and announces](/quest/m1/cluster-routing/routes.md), + which reshapes the lite-07 Announce stream and adds ANNOUNCE_FLOOR to + certify per-route completeness. CAUGHT_UP is per stream and orthogonal; + whichever lands second adapts its wire to the other. +- The moq-transport Active Count extension + (`drafts/draft-lcurley-moq-active-count.md`) is out of scope; it is a + separate published draft. + +Test on mock time, directly and through a relay: CAUGHT_UP after the +initial routes; an initial route ending before a stalled reader reaches it, +with CAUGHT_UP still arriving; a cold relay answering CAUGHT_UP at once; and +a lite-06 peer still getting `Active Count`. + +Public API: none, if consumers already expose "initial set done"; otherwise +the same signal as TRACKS, mirrored in Rust and `@moq/net`. Wire: lite-07-wip +ANNOUNCE_OK drops `Active Count` and the Announce stream gains CAUGHT_UP. + +## Related + +- [TRACKS](/quest/m1/archive/tracks.md) - the same event on the track listing diff --git a/quest/m1/archive/producer.md b/quest/m1/archive/producer.md index cf9dcdd5b80..eb9933a58b9 100644 --- a/quest/m1/archive/producer.md +++ b/quest/m1/archive/producer.md @@ -15,8 +15,12 @@ Decided by the maintainer on 2026-10-17 (see the [epic](/quest/m1/archive/README.md)): - **Input.** One producer records every broadcast under a prefix as it - appears: ANNOUNCE finds each broadcast, [TRACKS](/quest/m1/archive/tracks.md) - lists its tracks, and each track records through its own SUBSCRIBE + appears: ANNOUNCE finds each broadcast announced at its own path, + [TRACKS](/quest/m1/archive/tracks.md) lists its tracks, and each track + records through its own SUBSCRIBE. A covering-prefix route is capability, + not inventory, so broadcasts served under one on request without their own + announcement are not recorded, and a TRACKS refused on an announced path + means it is not a broadcast (decided 2026-10-19, replacing one SUBSCRIBE_ALL). The application maps each broadcast (path and epoch) to its key prefix, so a different epoch is a different prefix and the producer stores no epoch. Open since discovery diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 0c29659bb8e..c98605c2bd0 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -75,9 +75,11 @@ TRACKS' three events (see its Plan) map as follows: Untrusted input: - Inbound, a hostile subscriber's SUBSCRIBE_TRACKS makes us open a PUBLISH - per track. Each holds a subscription slot; past the cap, send - PUBLISH_SKIPPED (draft-20 on) rather than hold more. Drafts 18 and 19 have - no skip signal, so there the track is simply not published. + per track. Each holds a subscription slot; past the cap, tell the peer + rather than hold more or omit the track silently: PUBLISH_BLOCKED on + draft-18, PUBLISH_SKIPPED (its rename) from draft-19 on. Draft-17 has no + SUBSCRIBE_TRACKS (its PUBLISH half rode SUBSCRIBE_NAMESPACE, which stays + refused), so nothing changes there. - Outbound, a hostile publisher can PUBLISH without bound. Each held paused PUBLISH counts against the session's `announces` cap, as a lite listed track does; past it, refuse the PUBLISH (EXCESSIVE_LOAD) rather than hold more. The diff --git a/quest/m1/archive/tracks.md b/quest/m1/archive/tracks.md index 6249b1f87b4..28a8dc46600 100644 --- a/quest/m1/archive/tracks.md +++ b/quest/m1/archive/tracks.md @@ -46,7 +46,13 @@ Scope is one broadcast, decided by the maintainer on 2026-10-19. ANNOUNCE alread discovers broadcasts under a prefix, with their epochs and restarts; a prefix-scoped TRACKS would duplicate that and need every route handler to answer for a whole prefix (#5931's `Lister`). A prefix consumer composes the -two: ANNOUNCE for broadcasts, TRACKS per announced broadcast. TRACKS names a +two: ANNOUNCE for broadcasts, TRACKS per announced broadcast. A route is +capability, not inventory: a covering-prefix announcement names no +broadcast, and a service serving paths under it on request lists nothing, +like an on-demand track. A prefix consumer therefore covers the broadcasts +announced at their own path (the lite draft already says an advertiser +SHOULD announce a concrete path once it is producing) and treats TRACKS +refused on an announced path as "not a broadcast". TRACKS names a broadcast path and an Epoch and resolves like TRACK and SUBSCRIBE, so the listing is pinned to the instance that answers it and ends with that instance, never spliced into the next one. Rejected: a prefix-scoped TRACKS, @@ -67,26 +73,29 @@ when a track no longer exists"): length-prefixed TRACK_INFO, so one decoder serves both streams and any field TRACK_INFO gains (refresh, compress) reaches listings without a second wire change. It fits because TRACK_INFO holds only fixed per-track - properties and no name or stream-specific field. What differs is only what - wraps it: the name, and an implicit ID assigned per entry, as ANNOUNCE_START - assigns Announce IDs. A lister MAY skip the Track Stream for a track it - learned this way, since the properties are the same immutable ones. -- **Caught up.** An explicit CAUGHT_UP event, sent once, in order, when the - reader's cursor reaches what was the live head when it asked, so the lister - knows it holds every track this hop knew then; entries after it are live. - Not ANNOUNCE_OK's `Active Count`: a count sent first commits to that many - adds, so a track removed before a slow reader reaches its add would either - leave the count unmet forever or force the hop to retain every initial - entry per reader, which a stalled hostile reader pins. The event costs - about a byte more per stream and keeps each reader an O(1) cursor. Caught up means caught up with this hop, decided by the maintainer on + properties and no name or stream-specific field; the name is the only thing + wrapping it. A lister MAY skip the Track Stream for a track it learned this + way, since the properties are the same immutable ones. +- **Caught up.** An ordered CAUGHT_UP event, sent once, when the reader + reaches what was the live head when it asked, so the lister knows it holds + every track this hop knew then; entries after it are live. There is no + count anywhere, decided by the maintainer on 2026-10-19 ("We don't need the + count at all"): a count sent first commits to that many adds, which a track + removed before a slow reader reaches its add would break. The event itself + is the whole wire cost. The announce stream gets the same event in lite-07 + ([Announce CAUGHT_UP](/quest/m1/announce-caught-up.md)), identical in shape. + Caught up means caught up with this hop, decided by the maintainer on 2026-10-19: a relay answers at once with what it currently holds and never waits on its upstream, since relays never block on peers. A cold relay may report zero; the upstream's tracks then arrive as live adds. -- **Track removed.** A named event referencing the entry's ID, sent when the - publisher no longer serves the name (it left the broadcast's track table), - so a SUBSCRIBE for it would now be refused. A track created again under the - name gets a new entry and ID. Removing an ID that is not live is a protocol - violation, as on the Announce stream. +- **Track removed.** Names the track, sent when the publisher no longer + serves it (it left the broadcast's track table), so a SUBSCRIBE for it + would now be refused. There are no entry IDs: a name is unique among a + broadcast's live tracks, so the name identifies the entry, and a track + created again under it is a later add. Removing a name that is not live on + the stream is a protocol violation. A removal costs the name's bytes instead + of a varint, which is worth it: the receiver keys tracks by name anyway, and + the sender needs no per-reader ID state (below). Costs, accepted: @@ -126,15 +135,19 @@ Guidance: budget for discovery state a peer pushes at us; past it, the relay resets the listing rather than holding more. Names keep the existing length caps. - A hostile publisher can churn, adding and removing tracks as fast as it - can send. Held state is the live set, never the history, and a - downstream cursor reads the current set rather than a log: an add and its - removal that a slow reader has not reached collapse to nothing, before or - after CAUGHT_UP, so churn - costs a relay per-event work across its listings of that broadcast (the - same fan-out an Announce stream pays) but no backlog. Implicit IDs are - assigned per emitted add on each stream, never per source event, so a - collapsed pair consumes no ID and later removals stay in sync. They keep - growing, which costs a varint byte now and then, not state. + can send. The hop holds the live set, each entry stamped with the order it + was added, plus a tombstone for a removed entry while some reader that + already sent its add has not yet sent its removal. Each reader keeps two + positions: the last add it sent and the last removal it sent. It sends, + in the order events happened, every live entry added past its add + position and every tombstone past its removal position whose entry it + already passed. An add and its removal that a reader has not reached + collapse to nothing, before or after CAUGHT_UP, since the entry is gone and + it never passed it. A stalled reader pins only tombstones for entries it + passed, which were live when it stalled, so at most the live set (bounded + by the `announces` cap) per stalled reader; later churn is invisible to + it. Churn costs per-event work across a broadcast's listings (the same + fan-out an Announce stream pays), never a backlog. - A hostile subscriber can open many TRACKS. Each takes a subscription slot, downstream listings of one broadcast share one upstream TRACKS, and a downstream reader is a cursor into the shared listing, so a slow or stalled @@ -149,11 +162,12 @@ request arriving before CAUGHT_UP, a cold relay answering caught up at once with what it holds and the upstream's tracks following as live adds, a track added later (a SUBSCRIBE from the listing gets its first group from the cache), a track -removed and created again under its name (a new ID), add and remove churn -collapsing for a stalled reader (including an initial track removed before -the reader reaches it, with CAUGHT_UP still arriving and later IDs in sync), a removal of an unknown ID refused, the -broadcast -ending the listing, a new epoch at the same path not continuing an old +removed and created again under its name (removal sent before the new add), +add and remove churn collapsing for a stalled reader (including an initial +track removed before the reader reaches it, with CAUGHT_UP still arriving), +a stalled reader pinning no more than the live set under churn, a removal +naming a track that is not live refused, a TRACKS refused on a +covering-prefix route, the broadcast ending the listing, a new epoch at the same path not continuing an old listing, a dynamic-only track never created, two downstream listings sharing one upstream TRACKS, and the `announces` cap resetting an oversized listing. diff --git a/quest/m1/lite07-finalize.md b/quest/m1/lite07-finalize.md index 859f82dde17..0a62eb81d2e 100644 --- a/quest/m1/lite07-finalize.md +++ b/quest/m1/lite07-finalize.md @@ -91,6 +91,7 @@ is published; older versions are unchanged. - [Compressed tracks in Rust](/quest/m1/compress/core.md) - TRACK_INFO carries the compress flag and the frame header each compressed frame's inflated size - [Compressed tracks in js/net](/quest/m1/compress/js.md) - JS reads and writes compressed lite-07 tracks instead of refusing them - [TRACKS](/quest/m1/archive/tracks.md) - lite-07 carries the request listing a broadcast's tracks as they appear +- [Announce CAUGHT_UP](/quest/m1/announce-caught-up.md) - lite-07's Announce stream marks its initial set with CAUGHT_UP instead of `Active Count` ## Related From 11fbc1a2086e146801344852589398373e41eb13 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 13:48:48 -0700 Subject: [PATCH 06/20] quest(m1): a skipped IETF track is still listed PUBLISH_SKIPPED names a track that exists, so it is a listing add. A held paused SUBSCRIBE supplies its TRACK_INFO and its end, and failing that fails the listing. Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/subscribe-tracks.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index c98605c2bd0..7a04e838995 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -36,9 +36,9 @@ TRACKS' three events (see its Plan) map as follows: | TRACKS event | Inbound: we answer SUBSCRIBE_TRACKS | Outbound: we send it with FORWARD=0 | |---|---|---| -| Track added, with TRACK_INFO | a PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today | a PUBLISH; TRACK_INFO from its Track Properties | +| Track added, with TRACK_INFO | a PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today | a PUBLISH, TRACK_INFO from its Track Properties; or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18), TRACK_INFO from the SUBSCRIBE_OK of a paused SUBSCRIBE we open for it | | Caught up | nothing to send: moq-transport has no marker | not needed: the lite side sends CAUGHT_UP for this hop at once, and PUBLISHes arrive as live adds | -| Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH | PUBLISH_DONE with TRACK_ENDED on the paused PUBLISH; any other status fails the listing | +| Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH | PUBLISH_DONE with TRACK_ENDED on the held paused PUBLISH or SUBSCRIBE; any other status fails the listing | - **Caught up has no IETF equivalent, and needs none.** Caught up means caught up with this hop (see TRACKS), so a relay answers a lite TRACKS at @@ -59,6 +59,20 @@ TRACKS' three events (see its Plan) map as follows: requests again, which re-issues SUBSCRIBE_TRACKS. Never turn it into a removal, which would hide a live track for good. The cost is a paused subscription per listed track at both ends, bounded below. +- **A skipped track is still listed**, decided by the maintainer on + 2026-10-19. A PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) answering our + FORWARD=0 SUBSCRIBE_TRACKS names a track that exists, so it is an add, and + its data comes through a direct SUBSCRIBE like any listed track. It carries + only the name, with no Track Properties and no stream to end, so open a + paused SUBSCRIBE (FORWARD=0) for it and hold that as the entry, as the + paused PUBLISH is held for other tracks. Its SUBSCRIBE_OK supplies the + TRACK_INFO, so the add goes downstream once it arrives, and its + PUBLISH_DONE with TRACK_ENDED is the removal. A refused paused SUBSCRIBE, + or one ending with another status, fails the listing loudly, since nothing + else would ever say the track ended. That SUBSCRIBE needs the same request + credit the upstream may have run short of when it skipped, so an upstream + short on capacity fails and retries the listing rather than leave an entry + that is never removed. - **Prefix, not broadcast.** SUBSCRIBE_TRACKS matches every namespace under the prefix, so a listing of one broadcast filters to its exact namespace. A session may hold only one SUBSCRIBE_TRACKS per overlapping prefix @@ -100,7 +114,10 @@ SUBSCRIBE_TRACKS receives a PUBLISH per track, including a later track and a later broadcast under the prefix; a lite TRACKS over a moq-transport upstream lists its tracks with FORWARD=0, its SUBSCRIBE gets a new track's first group, a PUBLISH_DONE with TRACK_ENDED removes the entry, and one with -another status resets the listing; a moq-transport subscriber gets +another status resets the listing; a PUBLISH_SKIPPED (PUBLISH_BLOCKED on +draft-18) lists the track once its paused SUBSCRIBE answers, a direct +SUBSCRIBE gets its data, a TRACK_ENDED on that paused SUBSCRIBE removes it, +and a refused paused SUBSCRIBE resets the listing; a moq-transport subscriber gets PUBLISH_DONE when a lite track is removed; an unsolicited PUBLISH is refused. Public API: none beyond the TRACKS hook. Wire: moq-transport support for an From 8df1cc3331ae8bf5d40df3fdcadfc094a99725fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 13:58:29 -0700 Subject: [PATCH 07/20] quest(m1): answer SUBSCRIBE_TRACKS by FORWARD, resume the held PUBLISH Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/subscribe-tracks.md | 123 +++++++++++++++++++-------- 1 file changed, 86 insertions(+), 37 deletions(-) diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 7a04e838995..6c83c637a73 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -6,17 +6,20 @@ moq-net speaks moq-transport SUBSCRIBE_TRACKS (draft-18 on) in both directions through the [TRACKS](/quest/m1/archive/tracks.md) model hook, instead of refusing it with NOT_SUPPORTED: -- **Inbound.** A moq-transport subscriber's SUBSCRIBE_TRACKS gets a PUBLISH - for every track of every broadcast under its namespace prefix, including - later tracks and broadcasts, each served from the cache like a SUBSCRIBE. +- **Inbound.** A moq-transport subscriber's SUBSCRIBE_TRACKS lists every + track of every broadcast under its namespace prefix, including later tracks + and broadcasts, but never pushes their data unasked. The peer resumes or + SUBSCRIBEs what it wants, served from the cache like any SUBSCRIBE. - **Outbound.** A lite TRACKS whose broadcast is served by a moq-transport peer learns that peer's tracks by sending SUBSCRIBE_TRACKS with FORWARD=0 - and reading the PUBLISH each track produces, without its objects. Each - listed track is then SUBSCRIBEd like any other. + and reading the PUBLISH each track produces, without its objects. A listed + track's data comes by resuming that same subscription when a reader wants + it. An unsolicited inbound PUBLISH stays refused: moq-net never merges a pushed track into a pulled copy. A FORWARD=0 PUBLISH answering our own -SUBSCRIBE_TRACKS carries no data, so holding it is not that merge. +SUBSCRIBE_TRACKS carries data only once we resume it with the filter our own +SUBSCRIBE would send, so it is a pull, not that merge. ## Plan @@ -34,38 +37,73 @@ LARGEST_OBJECT, and no objects flow until the subscriber resumes it. TRACKS' three events (see its Plan) map as follows: -| TRACKS event | Inbound: we answer SUBSCRIBE_TRACKS | Outbound: we send it with FORWARD=0 | +| TRACKS event | Inbound: we answer SUBSCRIBE_TRACKS by its FORWARD | Outbound: we send it with FORWARD=0 | |---|---|---| -| Track added, with TRACK_INFO | a PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today | a PUBLISH, TRACK_INFO from its Track Properties; or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18), TRACK_INFO from the SUBSCRIBE_OK of a paused SUBSCRIBE we open for it | +| Track added, with TRACK_INFO | FORWARD=0: a paused PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today, or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) past the subscription cap. FORWARD=1 or omitted: a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) naming the track | a PUBLISH, TRACK_INFO from its Track Properties; or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18), TRACK_INFO from the SUBSCRIBE_OK of a paused SUBSCRIBE we open for it | | Caught up | nothing to send: moq-transport has no marker | not needed: the lite side sends CAUGHT_UP for this hop at once, and PUBLISHes arrive as live adds | -| Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH | PUBLISH_DONE with TRACK_ENDED on the held paused PUBLISH or SUBSCRIBE; any other status fails the listing | - +| Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH; nothing for a skipped track, whose removal reaches the peer only through its own SUBSCRIBE ending with TRACK_ENDED | PUBLISH_DONE with TRACK_ENDED on the held paused PUBLISH or SUBSCRIBE; any other status fails the listing | + +- **Inbound answers by the peer's FORWARD**, decided by the maintainer on + 2026-10-19. We never push every track's data unasked. + - FORWARD=0: a paused PUBLISH per track, which carries Track Properties and + a TRACK_ENDED removal, and PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) + past the subscription cap. The peer resumes the PUBLISH to get data, and + we serve the update's filter from the cache as we would a SUBSCRIBE's. On + draft-18 its SUBSCRIBE to a track we PUBLISHed fails with + DUPLICATE_SUBSCRIPTION, as section 5.1 requires. + - FORWARD=1, or omitted (it defaults to 1): no draft lets the publisher + downgrade it to a paused PUBLISH. Drafts 18 and 19 say the resulting + PUBLISHes "will set the FORWARD parameter to 1"; drafts 20 to 22 use the + SUBSCRIBE_TRACKS parameters as each PUBLISH's initial subscription + parameters, and forbid PUBLISH_STATE_NOTIFY from changing a + subscriber-controlled one unasked. So answer each listed track with + PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18), and the peer SUBSCRIBEs + what it wants. A skip holds no subscription and has no PUBLISH_DONE, so + a removal reaches the peer only through its own SUBSCRIBE. Draft-18 words + PUBLISH_BLOCKED for a publisher out of bidirectional streams only; + draft-19 on allow "any other reason". + - Rejected, an agent pick: refusing a FORWARD=1 request with REQUEST_ERROR. + It is legal, but lists nothing, and since FORWARD defaults to 1 it would + refuse every peer that omits the parameter. - **Caught up has no IETF equivalent, and needs none.** Caught up means caught up with this hop (see TRACKS), so a relay answers a lite TRACKS at once with what it holds, whatever its upstream speaks, and every PUBLISH the FORWARD=0 SUBSCRIBE_TRACKS brings is a live add. Inbound, nothing stands in for it. -- **Removal needs the PUBLISH kept.** PUBLISH_DONE is the only removal - signal, so an outbound PUBLISH is accepted paused (PUBLISH_OK, FORWARD=0) - and held as the listing entry, while data flows through a separate - SUBSCRIBE from the existing path, which joins from the cache like a lite - one. Multiple subscriptions to one track are allowed. Resuming the paused - PUBLISH instead starts at the live edge (draft-22 section 9.8), missing the - first groups. Only TRACK_ENDED ("no longer being published") is a removal, +- **One subscription per track: the held PUBLISH carries the data.** + PUBLISH_DONE is the only removal signal, so an outbound PUBLISH is accepted + paused (PUBLISH_OK, FORWARD=0) and held as the listing entry. It is also + the track's upstream copy: when a reader wants the data, REQUEST_UPDATE it + to FORWARD=1 with the filter our SUBSCRIBE would have sent + (SUBSCRIPTION_FILTER through draft-19, LOCATION_FILTER and FILL_PARAMETERS + from draft-20, whose section 3.6.2 names this join), and back to FORWARD=0 + when the last reader leaves, rather than unsubscribing. A second SUBSCRIBE + is not an option on every draft: draft-18 section 5.1 allows one + subscription per track per role and fails a second with + DUPLICATE_SUBSCRIPTION; draft-19 on allow several. Nothing is lost: the + upstream copy is one subscription carrying the union of reader ranges + either way, and readers keep their own lite subscriptions downstream. The + costs are that a listed track's upstream subscription lives as long as its + entry, paused while unread, and that the existing SUBSCRIBE path adopts a + held PUBLISH as the track's copy instead of opening one. On draft-18, a + PUBLISH for a track we already SUBSCRIBEd is that same duplicate, so the + existing SUBSCRIBE becomes the entry and the PUBLISH is refused with + DUPLICATE_SUBSCRIPTION; for a SUBSCRIBE still pending, end it first, as + section 5.1 requires. Only TRACK_ENDED ("no longer being published") is a removal, and a later PUBLISH for the name is a new add. Any other status (GOING_AWAY, EXCESSIVE_LOAD, ...) ends only that subscription while the track may still be published, and the peer need not PUBLISH it again, so it fails the listing loudly: the lite TRACKS stream resets and the lister requests again, which re-issues SUBSCRIBE_TRACKS. Never turn it into a - removal, which would hide a live track for good. The cost is a - paused subscription per listed track at both ends, bounded below. + removal, which would hide a live track for good; any reader on that copy + re-subscribes, as after any upstream loss. The cost is a paused + subscription per listed track at both ends, bounded below. - **A skipped track is still listed**, decided by the maintainer on 2026-10-19. A PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) answering our - FORWARD=0 SUBSCRIBE_TRACKS names a track that exists, so it is an add, and - its data comes through a direct SUBSCRIBE like any listed track. It carries - only the name, with no Track Properties and no stream to end, so open a - paused SUBSCRIBE (FORWARD=0) for it and hold that as the entry, as the - paused PUBLISH is held for other tracks. Its SUBSCRIBE_OK supplies the + FORWARD=0 SUBSCRIBE_TRACKS names a track that exists, so it is an add. It + carries only the name, with no Track Properties and no stream to end, so + open a paused SUBSCRIBE (FORWARD=0) for it and hold that as the entry and + the track's copy, resumed and paused for readers as the held PUBLISH is. Its SUBSCRIBE_OK supplies the TRACK_INFO, so the add goes downstream once it arrives, and its PUBLISH_DONE with TRACK_ENDED is the removal. A refused paused SUBSCRIBE, or one ending with another status, fails the listing loudly, since nothing @@ -88,12 +126,13 @@ TRACKS' three events (see its Plan) map as follows: Untrusted input: -- Inbound, a hostile subscriber's SUBSCRIBE_TRACKS makes us open a PUBLISH - per track. Each holds a subscription slot; past the cap, tell the peer - rather than hold more or omit the track silently: PUBLISH_BLOCKED on +- Inbound, a hostile subscriber's FORWARD=0 SUBSCRIBE_TRACKS makes us open a + paused PUBLISH per track. Each holds a subscription slot; past the cap, tell + the peer rather than hold more or omit the track silently: PUBLISH_BLOCKED on draft-18, PUBLISH_SKIPPED (its rename) from draft-19 on. Draft-17 has no SUBSCRIBE_TRACKS (its PUBLISH half rode SUBSCRIBE_NAMESPACE, which stays - refused), so nothing changes there. + refused), so nothing changes there. A FORWARD=1 listing holds no + subscription per track, only the listing reader's state, as on lite. - Outbound, a hostile publisher can PUBLISH without bound. Each held paused PUBLISH counts against the session's `announces` cap, as a lite listed track does; past it, refuse the PUBLISH (EXCESSIVE_LOAD) rather than hold more. The @@ -109,19 +148,29 @@ Guidance: passthrough suite where it exercises these messages. Update `doc/concept/standard.md`, which lists SUBSCRIBE_TRACKS as refused. -Test on mock time through a relay: a moq-transport subscriber's -SUBSCRIBE_TRACKS receives a PUBLISH per track, including a later track and a -later broadcast under the prefix; a lite TRACKS over a moq-transport upstream -lists its tracks with FORWARD=0, its SUBSCRIBE gets a new track's first -group, a PUBLISH_DONE with TRACK_ENDED removes the entry, and one with +Test on mock time through a relay: a moq-transport subscriber's FORWARD=0 +SUBSCRIBE_TRACKS receives a paused PUBLISH per track, including a later track +and a later broadcast under the prefix, with no objects until it resumes one, +and a PUBLISH_SKIPPED past the subscription cap; one with FORWARD=1 or none +receives a PUBLISH_SKIPPED per track and no PUBLISH, and its SUBSCRIBE to a +listed track gets data; both on draft-18 (PUBLISH_BLOCKED) and draft-19 on; a lite TRACKS over a moq-transport upstream +lists its tracks with FORWARD=0, a lite SUBSCRIBE resumes the held PUBLISH +with its join filter and gets a new track's first group with no second +upstream SUBSCRIBE (on draft-18 and draft-19 on), the last reader leaving +pauses it and keeps the entry, a PUBLISH_DONE with TRACK_ENDED removes the entry, and one with another status resets the listing; a PUBLISH_SKIPPED (PUBLISH_BLOCKED on -draft-18) lists the track once its paused SUBSCRIBE answers, a direct -SUBSCRIBE gets its data, a TRACK_ENDED on that paused SUBSCRIBE removes it, -and a refused paused SUBSCRIBE resets the listing; a moq-transport subscriber gets +draft-18) lists the track once its paused SUBSCRIBE answers, resuming it +gets the data, a TRACK_ENDED on that paused SUBSCRIBE removes it, +and a refused paused SUBSCRIBE resets the listing; on draft-18, a PUBLISH for +a track we already SUBSCRIBEd lists it through that SUBSCRIBE and is refused +with DUPLICATE_SUBSCRIPTION; a moq-transport subscriber resuming our paused +PUBLISH gets the groups its filter asks for from the cache; a moq-transport subscriber gets PUBLISH_DONE when a lite track is removed; an unsolicited PUBLISH is refused. Public API: none beyond the TRACKS hook. Wire: moq-transport support for an -existing message, and a FORWARD=0 PUBLISH held paused as a listing entry. +existing message, a FORWARD=0 PUBLISH held as a listing entry and resumed +for data, and an +inbound listing that answers FORWARD=1 with skips rather than data. ## Required From 75a338a0ddda1fa1d5a12607f37b5d595aeead45 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 14:20:01 -0700 Subject: [PATCH 08/20] quest(m1): refuse draft-18 forwarding listings, keep TRACKS refusal reasons Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-caught-up.md | 6 ++-- quest/m1/archive/producer.md | 6 ++-- quest/m1/archive/subscribe-tracks.md | 43 +++++++++++++++++----------- quest/m1/archive/tracks.md | 17 ++++++++--- 4 files changed, 46 insertions(+), 26 deletions(-) diff --git a/quest/m1/announce-caught-up.md b/quest/m1/announce-caught-up.md index 5ba86c82613..059f50df276 100644 --- a/quest/m1/announce-caught-up.md +++ b/quest/m1/announce-caught-up.md @@ -5,8 +5,8 @@ On lite-07, the Announce stream marks the end of its initial set with the same ordered CAUGHT_UP event [TRACKS](/quest/m1/archive/tracks.md) uses, replacing ANNOUNCE_OK's `Active Count`. A subscriber knows it holds every -route this hop knew when it asked, even when one of those routes ends before -a slow reader reaches it. Published versions keep `Active Count` unchanged. +route this hop knew when it asked that is still live; one that ends before a +slow reader reaches its ANNOUNCE_START is omitted, as in TRACKS. Published versions keep `Active Count` unchanged. ## Plan @@ -16,7 +16,7 @@ an initial route that ends before a slow reader reaches its start leaves the count unmet, unless the hop retains every initial announcement per reader. CAUGHT_UP is sent once, in order, when the reader reaches what was the live head when it asked, so an initial route that comes and goes before -then collapses like any other. Keep the event identical in shape to TRACKS' +then is omitted from the initial set, collapsing like any other. Keep the event identical in shape to TRACKS' CAUGHT_UP, so one encoding and one consumer rule serve both streams. - Caught up means caught up with this hop: a relay answers at once with what diff --git a/quest/m1/archive/producer.md b/quest/m1/archive/producer.md index eb9933a58b9..b79b53d3861 100644 --- a/quest/m1/archive/producer.md +++ b/quest/m1/archive/producer.md @@ -19,8 +19,10 @@ Decided by the maintainer on 2026-10-17 (see the [TRACKS](/quest/m1/archive/tracks.md) lists its tracks, and each track records through its own SUBSCRIBE. A covering-prefix route is capability, not inventory, so broadcasts served under one on request without their own - announcement are not recorded, and a TRACKS refused on an announced path - means it is not a broadcast + announcement are not recorded, and a TRACKS refused with NOT_FOUND on an + announced path means it is not a broadcast; any other refusal, such as + UNAUTHORIZED, is that broadcast's error and reaches the caller, never a + silent skip (decided 2026-10-19, replacing one SUBSCRIBE_ALL). The application maps each broadcast (path and epoch) to its key prefix, so a different epoch is a different prefix and the producer stores no epoch. Open since discovery diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 6c83c637a73..5f37928fa7f 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -33,13 +33,14 @@ maintainer declined. FORWARD=0, verified against the draft-18 and draft-22 text (draft-22 sections 3.1.1, 3.6, 9.8 and 9.20.18): each PUBLISH a SUBSCRIBE_TRACKS produces starts its subscription paused, carrying the full track name, Track Properties, and -LARGEST_OBJECT, and no objects flow until the subscriber resumes it. +LARGEST_OBJECT once the track has published an object (omitted before), and no +objects flow until the subscriber resumes it. TRACKS' three events (see its Plan) map as follows: | TRACKS event | Inbound: we answer SUBSCRIBE_TRACKS by its FORWARD | Outbound: we send it with FORWARD=0 | |---|---|---| -| Track added, with TRACK_INFO | FORWARD=0: a paused PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today, or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) past the subscription cap. FORWARD=1 or omitted: a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) naming the track | a PUBLISH, TRACK_INFO from its Track Properties; or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18), TRACK_INFO from the SUBSCRIBE_OK of a paused SUBSCRIBE we open for it | +| Track added, with TRACK_INFO | FORWARD=0: a paused PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today, or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) past the subscription cap. FORWARD=1 or omitted: a PUBLISH_SKIPPED naming the track from draft-19 on; on draft-18 the whole request is refused with REQUEST_ERROR | a PUBLISH, TRACK_INFO from its Track Properties; or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18), TRACK_INFO from the SUBSCRIBE_OK of a paused SUBSCRIBE we open for it | | Caught up | nothing to send: moq-transport has no marker | not needed: the lite side sends CAUGHT_UP for this hop at once, and PUBLISHes arrive as live adds | | Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH; nothing for a skipped track, whose removal reaches the peer only through its own SUBSCRIBE ending with TRACK_ENDED | PUBLISH_DONE with TRACK_ENDED on the held paused PUBLISH or SUBSCRIBE; any other status fails the listing | @@ -56,15 +57,16 @@ TRACKS' three events (see its Plan) map as follows: PUBLISHes "will set the FORWARD parameter to 1"; drafts 20 to 22 use the SUBSCRIBE_TRACKS parameters as each PUBLISH's initial subscription parameters, and forbid PUBLISH_STATE_NOTIFY from changing a - subscriber-controlled one unasked. So answer each listed track with - PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18), and the peer SUBSCRIBEs - what it wants. A skip holds no subscription and has no PUBLISH_DONE, so - a removal reaches the peer only through its own SUBSCRIBE. Draft-18 words - PUBLISH_BLOCKED for a publisher out of bidirectional streams only; - draft-19 on allow "any other reason". - - Rejected, an agent pick: refusing a FORWARD=1 request with REQUEST_ERROR. - It is legal, but lists nothing, and since FORWARD defaults to 1 it would - refuse every peer that omits the parameter. + subscriber-controlled one unasked. The maintainer's rule follows: "if we + can't deny their request and do forward=0 instead, then PUBLISH_SKIPPED". + From draft-19 on, which allows a skip "for any other reason", answer each + listed track with PUBLISH_SKIPPED, and the peer SUBSCRIBEs what it wants. + A skip holds no subscription and has no PUBLISH_DONE, so a removal + reaches the peer only through its own SUBSCRIBE. Draft-18 has no such + skip: PUBLISH_BLOCKED is only for a publisher out of bidirectional + streams, so it would falsely report exhaustion. There, refuse the whole + SUBSCRIBE_TRACKS with REQUEST_ERROR (NOT_SUPPORTED, its reason naming + FORWARD=0), and the peer retries with FORWARD=0 or SUBSCRIBEs directly. - **Caught up has no IETF equivalent, and needs none.** Caught up means caught up with this hop (see TRACKS), so a relay answers a lite TRACKS at once with what it holds, whatever its upstream speaks, and every PUBLISH the @@ -77,7 +79,11 @@ TRACKS' three events (see its Plan) map as follows: to FORWARD=1 with the filter our SUBSCRIBE would have sent (SUBSCRIPTION_FILTER through draft-19, LOCATION_FILTER and FILL_PARAMETERS from draft-20, whose section 3.6.2 names this join), and back to FORWARD=0 - when the last reader leaves, rather than unsubscribing. A second SUBSCRIBE + when the last reader leaves, rather than unsubscribing. On draft-18 and + draft-19 a filter does not deliver objects already published, so a start + below the Largest Object also sends a Joining FETCH on the held + subscription's request ID, as the existing pre-draft-20 SUBSCRIBE path + does (draft-18 section 5.1.3, draft-19 section 5.1.5). A second SUBSCRIBE is not an option on every draft: draft-18 section 5.1 allows one subscription per track per role and fails a second with DUPLICATE_SUBSCRIPTION; draft-19 on allow several. Nothing is lost: the @@ -129,7 +135,8 @@ Untrusted input: - Inbound, a hostile subscriber's FORWARD=0 SUBSCRIBE_TRACKS makes us open a paused PUBLISH per track. Each holds a subscription slot; past the cap, tell the peer rather than hold more or omit the track silently: PUBLISH_BLOCKED on - draft-18, PUBLISH_SKIPPED (its rename) from draft-19 on. Draft-17 has no + draft-18, where the cap is the bidirectional streams we allow ourselves for + PUBLISH, and PUBLISH_SKIPPED (its rename) from draft-19 on. Draft-17 has no SUBSCRIBE_TRACKS (its PUBLISH half rode SUBSCRIBE_NAMESPACE, which stays refused), so nothing changes there. A FORWARD=1 listing holds no subscription per track, only the listing reader's state, as on lite. @@ -151,12 +158,14 @@ Guidance: Test on mock time through a relay: a moq-transport subscriber's FORWARD=0 SUBSCRIBE_TRACKS receives a paused PUBLISH per track, including a later track and a later broadcast under the prefix, with no objects until it resumes one, -and a PUBLISH_SKIPPED past the subscription cap; one with FORWARD=1 or none -receives a PUBLISH_SKIPPED per track and no PUBLISH, and its SUBSCRIBE to a -listed track gets data; both on draft-18 (PUBLISH_BLOCKED) and draft-19 on; a lite TRACKS over a moq-transport upstream +and a PUBLISH_SKIPPED past the subscription cap (PUBLISH_BLOCKED on +draft-18); one with FORWARD=1 or none receives a PUBLISH_SKIPPED per track and +no PUBLISH from draft-19 on, and its SUBSCRIBE to a listed track gets data, +while on draft-18 it gets REQUEST_ERROR; a lite TRACKS over a moq-transport upstream lists its tracks with FORWARD=0, a lite SUBSCRIBE resumes the held PUBLISH with its join filter and gets a new track's first group with no second -upstream SUBSCRIBE (on draft-18 and draft-19 on), the last reader leaving +upstream SUBSCRIBE (on draft-18 and draft-19 on), a past start on draft-18 +and draft-19 gets the cached groups through a Joining FETCH, the last reader leaving pauses it and keeps the entry, a PUBLISH_DONE with TRACK_ENDED removes the entry, and one with another status resets the listing; a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) lists the track once its paused SUBSCRIBE answers, resuming it diff --git a/quest/m1/archive/tracks.md b/quest/m1/archive/tracks.md index 28a8dc46600..4ec55d2f3d2 100644 --- a/quest/m1/archive/tracks.md +++ b/quest/m1/archive/tracks.md @@ -52,7 +52,10 @@ broadcast, and a service serving paths under it on request lists nothing, like an on-demand track. A prefix consumer therefore covers the broadcasts announced at their own path (the lite draft already says an advertiser SHOULD announce a concrete path once it is producing) and treats TRACKS -refused on an announced path as "not a broadcast". TRACKS names a +refused with NOT_FOUND on an announced path as "not a broadcast". Any other +refusal, UNAUTHORIZED included, keeps its reason and reaches the caller as +that broadcast's error, since a real broadcast refuses a lister without +permission. TRACKS names a broadcast path and an Epoch and resolves like TRACK and SUBSCRIBE, so the listing is pinned to the instance that answers it and ends with that instance, never spliced into the next one. Rejected: a prefix-scoped TRACKS, @@ -77,8 +80,10 @@ when a track no longer exists"): wrapping it. A lister MAY skip the Track Stream for a track it learned this way, since the properties are the same immutable ones. - **Caught up.** An ordered CAUGHT_UP event, sent once, when the reader - reaches what was the live head when it asked, so the lister knows it holds - every track this hop knew then; entries after it are live. There is no + reaches what was the live head when it asked. The lister then holds every + track this hop knew then that is still live: one removed before the reader + reached its add collapses with it and never appears (see churn below). + Entries after it are live. There is no count anywhere, decided by the maintainer on 2026-10-19 ("We don't need the count at all"): a count sent first commits to that many adds, which a track removed before a slow reader reaches its add would break. The event itself @@ -92,7 +97,11 @@ when a track no longer exists"): serves it (it left the broadcast's track table), so a SUBSCRIBE for it would now be refused. There are no entry IDs: a name is unique among a broadcast's live tracks, so the name identifies the entry, and a track - created again under it is a later add. Removing a name that is not live on + created again under it is a later add of the same track: within one + broadcast instance a name always means the same content, so its group + sequences continue rather than restart. A publisher that restarts them is + buggy and publishes a new broadcast (a new Epoch) instead, so an archive + keyed by broadcast, track, and first group never collides on a re-add. Removing a name that is not live on the stream is a protocol violation. A removal costs the name's bytes instead of a varint, which is worth it: the receiver keys tracks by name anyway, and the sender needs no per-reader ID state (below). From 60dd16e2fb898976180eab947c8bf79e0c3b11e7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 14:28:21 -0700 Subject: [PATCH 09/20] quest(m1): one SUBSCRIBE_TRACKS per session, widened in place Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/subscribe-tracks.md | 34 +++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 5f37928fa7f..666328cc9a7 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -117,11 +117,30 @@ TRACKS' three events (see its Plan) map as follows: credit the upstream may have run short of when it skipped, so an upstream short on capacity fails and retries the listing rather than leave an entry that is never removed. -- **Prefix, not broadcast.** SUBSCRIBE_TRACKS matches every namespace under - the prefix, so a listing of one broadcast filters to its exact namespace. - A session may hold only one SUBSCRIBE_TRACKS per overlapping prefix - (PREFIX_OVERLAP), so lite TRACKS for a namespace and one nested under it - share one upstream request. +- **One SUBSCRIBE_TRACKS per session, widened in place.** SUBSCRIBE_TRACKS + matches every namespace under its prefix, and a session may not hold two + whose prefixes "share a common prefix" (PREFIX_OVERLAP). Read loosely, that + bans even siblings like `/a/b` and `/a/c`, and asking for the exact + namespace does not avoid it, since `/a` is a prefix of `/a/b`. So keep one + outbound SUBSCRIBE_TRACKS per session, its prefix the longest common prefix + of every broadcast listed through that session, and filter each lite + listing to its exact namespace. + - A listing outside the prefix, such as TRACKS(`/a`) after TRACKS(`/a/b`), + widens it with REQUEST_UPDATE carrying TRACK_NAMESPACE_PREFIX (draft-18 + on). The overlap rule checks only other requests, so updating our own one + is legal. The update leaves existing subscriptions alone (draft-22 + section 9.5.2), so the held PUBLISHes stay. The newly covered tracks + arrive as PUBLISHes, and PUBLISH_SKIPPED suffixes are read against the + updated prefix. + - A refused update fails the new listing loudly, never silently narrows + it. + - The prefix never narrows while listings remain. The request is cancelled + when the last one closes. + - Cost: tracks of unlisted broadcasts between the listed ones are held too. + They are bounded by the `announces` cap below, and past it the listings + reset as for any other overflow. + - Rejected: cancelling the narrow request and sending a broader one. The + peer would PUBLISH the held tracks again, a duplicate on draft-18. - **Push inventory only.** An IETF relay lists the tracks it has been PUBLISHed or already holds, not what an upstream would serve on SUBSCRIBE. A track served only on request is unlisted, which matches TRACKS' rule. @@ -170,7 +189,10 @@ pauses it and keeps the entry, a PUBLISH_DONE with TRACK_ENDED removes the entry another status resets the listing; a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) lists the track once its paused SUBSCRIBE answers, resuming it gets the data, a TRACK_ENDED on that paused SUBSCRIBE removes it, -and a refused paused SUBSCRIBE resets the listing; on draft-18, a PUBLISH for +and a refused paused SUBSCRIBE resets the listing; TRACKS(`/a/b`) then +TRACKS(`/a`) on one session widens the one SUBSCRIBE_TRACKS, lists `/a`'s +tracks, and keeps `/a/b`'s held PUBLISHes, and the reverse order shares it +unchanged; on draft-18, a PUBLISH for a track we already SUBSCRIBEd lists it through that SUBSCRIBE and is refused with DUPLICATE_SUBSCRIPTION; a moq-transport subscriber resuming our paused PUBLISH gets the groups its filter asks for from the cache; a moq-transport subscriber gets From a6c849968c44d908e79011c7993033eed8c648b3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 14:30:30 -0700 Subject: [PATCH 10/20] quest(m1): strict prefix overlap, siblings get their own SUBSCRIBE_TRACKS Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/subscribe-tracks.md | 63 ++++++++++++++++++---------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 666328cc9a7..a02b136f940 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -117,30 +117,45 @@ TRACKS' three events (see its Plan) map as follows: credit the upstream may have run short of when it skipped, so an upstream short on capacity fails and retries the listing rather than leave an entry that is never removed. -- **One SUBSCRIBE_TRACKS per session, widened in place.** SUBSCRIBE_TRACKS - matches every namespace under its prefix, and a session may not hold two - whose prefixes "share a common prefix" (PREFIX_OVERLAP). Read loosely, that - bans even siblings like `/a/b` and `/a/c`, and asking for the exact - namespace does not avoid it, since `/a` is a prefix of `/a/b`. So keep one - outbound SUBSCRIBE_TRACKS per session, its prefix the longest common prefix - of every broadcast listed through that session, and filter each lite - listing to its exact namespace. - - A listing outside the prefix, such as TRACKS(`/a`) after TRACKS(`/a/b`), - widens it with REQUEST_UPDATE carrying TRACK_NAMESPACE_PREFIX (draft-18 - on). The overlap rule checks only other requests, so updating our own one - is legal. The update leaves existing subscriptions alone (draft-22 - section 9.5.2), so the held PUBLISHes stay. The newly covered tracks - arrive as PUBLISHes, and PUBLISH_SKIPPED suffixes are read against the - updated prefix. +- **One SUBSCRIBE_TRACKS per nesting chain, widened in place.** + SUBSCRIBE_TRACKS matches every namespace under its prefix, and a session + may not hold two whose prefixes "share a common prefix" (PREFIX_OVERLAP). + Read that strictly: two prefixes overlap only when one is a prefix of the + other, so their matched namespaces intersect. Draft-14 section 9.28, where + the rule began for SUBSCRIBE_NAMESPACE, says "a prefix of, suffix of, or + equal to"; draft-16 section 9.25 rephrased it as "shares a common prefix" + under "A subscriber cannot make overlapping namespace subscriptions"; and + siblings such as `/a/b` and `/a/c` match disjoint namespaces. No draft + forbids siblings outright. Asking for the exact namespace does not avoid + the rule, since `/a` is a prefix of `/a/b`, so: + - Siblings get separate SUBSCRIBE_TRACKS requests. + - Nested listings share one request at the broader of the two, never a + common ancestor nobody listed. TRACKS(`/a`) after TRACKS(`/a/b`) widens + the request in place with REQUEST_UPDATE carrying TRACK_NAMESPACE_PREFIX + (draft-18 on); the overlap rule checks only other requests, so updating + our own is legal. The update leaves existing subscriptions alone + (draft-22 section 9.5.2), so the held PUBLISHes stay; the newly covered + tracks arrive as PUBLISHes, and PUBLISH_SKIPPED suffixes are read against + the updated prefix. + - A broader listing over several sibling requests, such as TRACKS(`/a`) + over `/a/b` and `/a/c`, folds them. The widened prefix may not overlap + another request, so cancel the others first and then widen one. The + cancel ends only those requests, and the PUBLISH subscriptions they + produced keep their own streams (draft-18 section 6.1), so their entries + stay. If the widened request PUBLISHes a track we already hold, keep the + held one as the entry and refuse the new one: DUPLICATE_SUBSCRIPTION on + draft-18, UNINTERESTED from draft-19. + - Each lite listing filters its request's results to its exact namespace. - A refused update fails the new listing loudly, never silently narrows - it. - - The prefix never narrows while listings remain. The request is cancelled - when the last one closes. - - Cost: tracks of unlisted broadcasts between the listed ones are held too. - They are bounded by the `announces` cap below, and past it the listings + it. A request never narrows while a listing under it remains, and is + cancelled when the last one closes. + - Cost: the extra tracks held are only those of broadcasts nested under a + listed one, bounded by the `announces` cap below; past it the listings reset as for any other overflow. - - Rejected: cancelling the narrow request and sending a broader one. The - peer would PUBLISH the held tracks again, a duplicate on draft-18. + - Rejected: cancelling the narrow request and sending a broader one, since + the peer would PUBLISH the held tracks again, a duplicate on draft-18; + and one request per session at the common ancestor, which for `/a/b` + and `/c/d` is the root and holds every track the peer has. - **Push inventory only.** An IETF relay lists the tracks it has been PUBLISHed or already holds, not what an upstream would serve on SUBSCRIBE. A track served only on request is unlisted, which matches TRACKS' rule. @@ -192,7 +207,9 @@ gets the data, a TRACK_ENDED on that paused SUBSCRIBE removes it, and a refused paused SUBSCRIBE resets the listing; TRACKS(`/a/b`) then TRACKS(`/a`) on one session widens the one SUBSCRIBE_TRACKS, lists `/a`'s tracks, and keeps `/a/b`'s held PUBLISHes, and the reverse order shares it -unchanged; on draft-18, a PUBLISH for +unchanged; TRACKS(`/a/b`) and TRACKS(`/a/c`) open two requests and hold no +track outside them, and a later TRACKS(`/a`) folds both into one, keeping +every held entry and refusing any duplicate PUBLISH; on draft-18, a PUBLISH for a track we already SUBSCRIBEd lists it through that SUBSCRIBE and is refused with DUPLICATE_SUBSCRIPTION; a moq-transport subscriber resuming our paused PUBLISH gets the groups its filter asks for from the cache; a moq-transport subscriber gets From e1f1d3a56445091afc96ee8ebf79518be960c865 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:04:51 -0700 Subject: [PATCH 11/20] quest(m1): SYNCED on first drain, per-reader queues, reset on refused widen Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 2 +- quest/m1/announce-caught-up.md | 49 ----------------------- quest/m1/announce-synced.md | 57 +++++++++++++++++++++++++++ quest/m1/archive/README.md | 2 +- quest/m1/archive/subscribe-tracks.md | 16 +++++--- quest/m1/archive/tracks.md | 58 ++++++++++++++-------------- quest/m1/lite07-finalize.md | 2 +- 7 files changed, 100 insertions(+), 86 deletions(-) delete mode 100644 quest/m1/announce-caught-up.md create mode 100644 quest/m1/announce-synced.md diff --git a/quest/m1/README.md b/quest/m1/README.md index f43559da026..5c0fbf75a70 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -65,7 +65,7 @@ lands before the cut. - [Claim answer epochs](/quest/m1/claim-answer-epochs.md) - a claim answers each output by publishing it at its exact path under its own epoch, so a restarted output never reaches a returning reader as the old one's cache - [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - every stream group in a lite subscription arrives or is dropped by name, and lite-07 drops its stream count for it - [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - a lite-07 SUBSCRIBE asks for past and live ranges with absolute or relative ends, in either order, and replaces FETCH; merged ranges never starve a subscriber; relays fill misses by range, including over moq-transport -- [Announce CAUGHT_UP](/quest/m1/announce-caught-up.md) - lite-07's Announce stream marks its initial set with the same ordered CAUGHT_UP as TRACKS, replacing `Active Count` +- [Announce SYNCED](/quest/m1/announce-synced.md) - lite-07's Announce stream marks its initial set with the same ordered SYNCED as TRACKS, replacing `Active Count` - [More tests under load](/quest/m1/test-flakes-2/README.md) - the second round of load-only failures, one quest per flake, fixed at the cause - [A public model](/quest/m1/public-model/README.md) - every model item a built-in session reaches is public, unnecessary, or marked deliberately private, so a custom publisher, subscriber, or relay builds from the public model alone - [Lazy discovery](/quest/m1/lazy-discovery/README.md) - a `consume` session requests announcements only for prefixes the origin is watching, in JS then Rust; `follow`, `broadcasts`, and the `discovery` option go away diff --git a/quest/m1/announce-caught-up.md b/quest/m1/announce-caught-up.md deleted file mode 100644 index 059f50df276..00000000000 --- a/quest/m1/announce-caught-up.md +++ /dev/null @@ -1,49 +0,0 @@ -# [S] Announce CAUGHT_UP - -## Goal - -On lite-07, the Announce stream marks the end of its initial set with the -same ordered CAUGHT_UP event [TRACKS](/quest/m1/archive/tracks.md) uses, -replacing ANNOUNCE_OK's `Active Count`. A subscriber knows it holds every -route this hop knew when it asked that is still live; one that ends before a -slow reader reaches its ANNOUNCE_START is omitted, as in TRACKS. Published versions keep `Active Count` unchanged. - -## Plan - -Decided by the maintainer on 2026-10-19 with TRACKS ("We don't need the -count at all"). A count sent first commits to that many ANNOUNCE_STARTs, so -an initial route that ends before a slow reader reaches its start leaves -the count unmet, unless the hop retains every initial announcement per -reader. CAUGHT_UP is sent once, in order, when the reader reaches what was -the live head when it asked, so an initial route that comes and goes before -then is omitted from the initial set, collapsing like any other. Keep the event identical in shape to TRACKS' -CAUGHT_UP, so one encoding and one consumer rule serve both streams. - -- Caught up means caught up with this hop: a relay answers at once with what - it holds and never waits on its upstream (relays never block on peers). -- lite-07's ANNOUNCE_OK loses `Active Count`; no count field remains. Update - `drafts/draft-lcurley-moq-lite.md` (ANNOUNCE_OK, the Announce stream text, - and the lite-07 changelog), then run `just drafts check` and - `just test interop --all`. -- Rust and JS consumers that batch the initial set (such as `moq ls`) wait - for CAUGHT_UP on lite-07 and keep reading the count on older versions. -- Coordinate with [Routes and announces](/quest/m1/cluster-routing/routes.md), - which reshapes the lite-07 Announce stream and adds ANNOUNCE_FLOOR to - certify per-route completeness. CAUGHT_UP is per stream and orthogonal; - whichever lands second adapts its wire to the other. -- The moq-transport Active Count extension - (`drafts/draft-lcurley-moq-active-count.md`) is out of scope; it is a - separate published draft. - -Test on mock time, directly and through a relay: CAUGHT_UP after the -initial routes; an initial route ending before a stalled reader reaches it, -with CAUGHT_UP still arriving; a cold relay answering CAUGHT_UP at once; and -a lite-06 peer still getting `Active Count`. - -Public API: none, if consumers already expose "initial set done"; otherwise -the same signal as TRACKS, mirrored in Rust and `@moq/net`. Wire: lite-07-wip -ANNOUNCE_OK drops `Active Count` and the Announce stream gains CAUGHT_UP. - -## Related - -- [TRACKS](/quest/m1/archive/tracks.md) - the same event on the track listing diff --git a/quest/m1/announce-synced.md b/quest/m1/announce-synced.md new file mode 100644 index 00000000000..6d6a1069571 --- /dev/null +++ b/quest/m1/announce-synced.md @@ -0,0 +1,57 @@ +# [S] Announce SYNCED + +## Goal + +On lite-07, the Announce stream marks the end of its initial set with the +same ordered SYNCED event [TRACKS](/quest/m1/archive/tracks.md) uses, +replacing ANNOUNCE_OK's `Active Count`. A subscriber knows it holds every +route this hop held that is still live; one that ends before a slow reader +reaches its ANNOUNCE_START is omitted, as in TRACKS. Published versions keep +`Active Count` unchanged. + +## Plan + +Decided by the maintainer on 2026-10-19 with TRACKS ("We don't need the +count at all"), and named SYNCED the same day. A count sent first commits to +that many ANNOUNCE_STARTs, so an initial route that ends before a slow reader +reaches its start leaves the count unmet, unless the hop retains every +initial announcement per reader. SYNCED is sent once, in order, the first +time the reader's queue drains (its `poll_next` returns Pending). An initial +route that comes and goes before then is omitted, collapsing in the reader's +pending queue like any other, and a retraction of a route the reader saw may +land before SYNCED. Keep the event identical in shape to TRACKS' SYNCED, so +one encoding and one consumer rule serve both streams. + +- Synced means synced with this hop: a relay answers at once with what it + holds and never waits on its upstream (relays never block on peers). +- The in-process consumers already expose this as synced + (`AnnounceConsumer::poll_synced` in `rs/moq-net/src/model/origin.rs`, + `synced` in `js/net/src/announced.ts`); the wire event feeds the same + signal. +- lite-07's ANNOUNCE_OK loses `Active Count`; no count field remains. Update + `drafts/draft-lcurley-moq-lite.md` (ANNOUNCE_OK, the Announce stream text, + and the lite-07 changelog), then run `just drafts check` and + `just test interop --all`. +- Rust and JS consumers that batch the initial set (such as `moq ls`) wait + for SYNCED on lite-07 and keep reading the count on older versions. +- Coordinate with [Routes and announces](/quest/m1/cluster-routing/routes.md), + which reshapes the lite-07 Announce stream and adds ANNOUNCE_FLOOR to + certify per-route completeness. SYNCED is per stream and orthogonal; + whichever lands second adapts its wire to the other. +- The moq-transport Active Count extension + (`drafts/draft-lcurley-moq-active-count.md`) is out of scope; it is a + separate published draft. + +Test on mock time, directly and through a relay: SYNCED after the initial +routes, sent the first time the reader's queue drains; an initial route +ending before a stalled reader reaches it, with SYNCED still arriving; a +retraction of a seen route landing before SYNCED; a cold relay answering +SYNCED at once; and a lite-06 peer still getting `Active Count`. + +Public API: none, if consumers already expose "initial set done"; otherwise +the same signal as TRACKS, mirrored in Rust and `@moq/net`. Wire: lite-07-wip +ANNOUNCE_OK drops `Active Count` and the Announce stream gains SYNCED. + +## Related + +- [TRACKS](/quest/m1/archive/tracks.md) - the same event on the track listing diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 3ea3593c774..e7e754e229f 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -79,7 +79,7 @@ from a host. Each child fixes the stale docs it touches. ## Required -- [TRACKS](/quest/m1/archive/tracks.md) - a moq-lite-07 request lists a broadcast's tracks with their TRACK_INFO, marks when it has caught up, and names removed tracks, for the subscriber to SUBSCRIBE per track, and relays fan it out from one upstream request +- [TRACKS](/quest/m1/archive/tracks.md) - a moq-lite-07 request lists a broadcast's tracks with their TRACK_INFO, marks when it is synced, and names removed tracks, for the subscriber to SUBSCRIBE per track, and relays fan it out from one upstream request - [Archive producer](/quest/m1/archive/producer.md) - `archive::Producer` stores every matching broadcast as duration-cut per-track objects keyed by group and pts, recording gaps instead of stalling, with an optional DVR mode - [Archive consumer](/quest/m1/archive/consumer.md) - `archive::Consumer` serves a stored prefix as a broadcast from its key listing, and `moq export/import archive` use the new pair - [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the root `archive` section is gone; an opt-in per-rendition `hls` field marks each aligned HLS segment's first group and pts and declares its nominal duration diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index a02b136f940..7c12ba7d1e1 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -41,7 +41,7 @@ TRACKS' three events (see its Plan) map as follows: | TRACKS event | Inbound: we answer SUBSCRIBE_TRACKS by its FORWARD | Outbound: we send it with FORWARD=0 | |---|---|---| | Track added, with TRACK_INFO | FORWARD=0: a paused PUBLISH, its Track Properties carrying TRACK_INFO as SUBSCRIBE_OK does today, or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) past the subscription cap. FORWARD=1 or omitted: a PUBLISH_SKIPPED naming the track from draft-19 on; on draft-18 the whole request is refused with REQUEST_ERROR | a PUBLISH, TRACK_INFO from its Track Properties; or a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18), TRACK_INFO from the SUBSCRIBE_OK of a paused SUBSCRIBE we open for it | -| Caught up | nothing to send: moq-transport has no marker | not needed: the lite side sends CAUGHT_UP for this hop at once, and PUBLISHes arrive as live adds | +| Synced | nothing to send: moq-transport has no marker | not needed: the lite side sends SYNCED for this hop at once, and PUBLISHes arrive as live adds | | Track removed | PUBLISH_DONE with TRACK_ENDED on that PUBLISH; nothing for a skipped track, whose removal reaches the peer only through its own SUBSCRIBE ending with TRACK_ENDED | PUBLISH_DONE with TRACK_ENDED on the held paused PUBLISH or SUBSCRIBE; any other status fails the listing | - **Inbound answers by the peer's FORWARD**, decided by the maintainer on @@ -67,8 +67,8 @@ TRACKS' three events (see its Plan) map as follows: streams, so it would falsely report exhaustion. There, refuse the whole SUBSCRIBE_TRACKS with REQUEST_ERROR (NOT_SUPPORTED, its reason naming FORWARD=0), and the peer retries with FORWARD=0 or SUBSCRIBEs directly. -- **Caught up has no IETF equivalent, and needs none.** Caught up means - caught up with this hop (see TRACKS), so a relay answers a lite TRACKS at +- **SYNCED has no IETF equivalent, and needs none.** Synced means synced + with this hop (see TRACKS), so a relay answers a lite TRACKS at once with what it holds, whatever its upstream speaks, and every PUBLISH the FORWARD=0 SUBSCRIBE_TRACKS brings is a live add. Inbound, nothing stands in for it. @@ -146,8 +146,10 @@ TRACKS' three events (see its Plan) map as follows: held one as the entry and refuse the new one: DUPLICATE_SUBSCRIPTION on draft-18, UNINTERESTED from draft-19. - Each lite listing filters its request's results to its exact namespace. - - A refused update fails the new listing loudly, never silently narrows - it. A request never narrows while a listing under it remains, and is + - A refused update resets every affected listing loudly, so each + re-requests: the new one, and any whose sibling request was cancelled + for the fold, since that request no longer reports adds for it. Never + silently narrow. A request never narrows while a listing under it remains, and is cancelled when the last one closes. - Cost: the extra tracks held are only those of broadcasts nested under a listed one, bounded by the `announces` cap below; past it the listings @@ -209,7 +211,9 @@ TRACKS(`/a`) on one session widens the one SUBSCRIBE_TRACKS, lists `/a`'s tracks, and keeps `/a/b`'s held PUBLISHes, and the reverse order shares it unchanged; TRACKS(`/a/b`) and TRACKS(`/a/c`) open two requests and hold no track outside them, and a later TRACKS(`/a`) folds both into one, keeping -every held entry and refusing any duplicate PUBLISH; on draft-18, a PUBLISH for +every held entry and refusing any duplicate PUBLISH; when widening `/a/b` to +`/a` is refused after `/a/c` was cancelled, the `/a` and `/a/c` listings +reset and re-request, while `/a/b` keeps its request; on draft-18, a PUBLISH for a track we already SUBSCRIBEd lists it through that SUBSCRIBE and is refused with DUPLICATE_SUBSCRIPTION; a moq-transport subscriber resuming our paused PUBLISH gets the groups its filter asks for from the cache; a moq-transport subscriber gets diff --git a/quest/m1/archive/tracks.md b/quest/m1/archive/tracks.md index 4ec55d2f3d2..ede63be869c 100644 --- a/quest/m1/archive/tracks.md +++ b/quest/m1/archive/tracks.md @@ -3,8 +3,8 @@ ## Goal A moq-lite-07 TRACKS request lists the tracks of one broadcast as its -producer creates them, each with its name and TRACK_INFO, marks when the -lister has caught up with every track that existed when it asked, and names +producer creates them, each with its name and TRACK_INFO, marks with SYNCED +when the lister has drained what this hop held, and names each track that no longer exists. The subscriber then SUBSCRIBEs to each track it wants, so delivery stays pull: nothing is ever pushed into a pulled copy. A listing never creates a track the publisher serves only on request (an any-name @@ -79,17 +79,19 @@ when a track no longer exists"): properties and no name or stream-specific field; the name is the only thing wrapping it. A lister MAY skip the Track Stream for a track it learned this way, since the properties are the same immutable ones. -- **Caught up.** An ordered CAUGHT_UP event, sent once, when the reader - reaches what was the live head when it asked. The lister then holds every - track this hop knew then that is still live: one removed before the reader - reached its add collapses with it and never appears (see churn below). - Entries after it are live. There is no +- **Synced.** An ordered SYNCED event, sent once, the first time the + reader's queue drains: the first time its `poll_next` returns Pending. + That is the whole rule, decided by the maintainer on 2026-10-19 (renamed + from CAUGHT_UP the same day). A removal may land before SYNCED, and an add + and removal the reader has not reached collapse (see churn below), so the + lister then holds every track this hop held that is still live. Entries + after it are live. There is no count anywhere, decided by the maintainer on 2026-10-19 ("We don't need the count at all"): a count sent first commits to that many adds, which a track removed before a slow reader reaches its add would break. The event itself is the whole wire cost. The announce stream gets the same event in lite-07 - ([Announce CAUGHT_UP](/quest/m1/announce-caught-up.md)), identical in shape. - Caught up means caught up with this hop, decided by the maintainer on + ([Announce SYNCED](/quest/m1/announce-synced.md)), identical in shape. + Synced means synced with this hop, decided by the maintainer on 2026-10-19: a relay answers at once with what it currently holds and never waits on its upstream, since relays never block on peers. A cold relay may report zero; the upstream's tracks then arrive as live adds. @@ -144,37 +146,37 @@ Guidance: budget for discovery state a peer pushes at us; past it, the relay resets the listing rather than holding more. Names keep the existing length caps. - A hostile publisher can churn, adding and removing tracks as fast as it - can send. The hop holds the live set, each entry stamped with the order it - was added, plus a tombstone for a removed entry while some reader that - already sent its add has not yet sent its removal. Each reader keeps two - positions: the last add it sent and the last removal it sent. It sends, - in the order events happened, every live entry added past its add - position and every tombstone past its removal position whose entry it - already passed. An add and its removal that a reader has not reached - collapse to nothing, before or after CAUGHT_UP, since the entry is gone and - it never passed it. A stalled reader pins only tombstones for entries it - passed, which were live when it stalled, so at most the live set (bounded - by the `announces` cap) per stalled reader; later churn is invisible to - it. Churn costs per-event work across a broadcast's listings (the same - fan-out an Announce stream pays), never a backlog. + can send. Each reader keeps a pending queue keyed by name, as origin + announcement consumers do (`OriginConsumerState` in + `rs/moq-net/src/model/origin.rs`), decided by the maintainer on + 2026-10-19. A removal for an add still queued for that reader cancels + both, before or after SYNCED. A removal for an add it already saw is + queued once, and a later add of the name joins it, as origin's + `UnannounceAnnounce` does. A reader's state is therefore bounded by the + names it saw plus the live set (bounded by the `announces` cap), however + long it stalls, with no global budget. Churn costs per-event work across + a broadcast's listings (the same fan-out an Announce stream pays), never + a backlog. - A hostile subscriber can open many TRACKS. Each takes a subscription slot, downstream listings of one broadcast share one upstream TRACKS, and a - downstream reader is a cursor into the shared listing, so a slow or stalled - reader costs flow control, not a copy of the listing. + slow or stalled reader holds only its pending queue, bounded as above, + not a copy of the listing's history. - Fan-out: benchmark downstream listings against tracks per broadcast and broadcasts per relay, per the AGENTS.md fan-out rule; a new track's cost must not grow with the broadcast count. - Split the JS half into its own quest if the PR grows too large to review. Test on mock time, directly and through a relay: tracks present at the -request arriving before CAUGHT_UP, a cold relay answering -caught up at once with what it holds and the upstream's tracks following as +request arriving before SYNCED, SYNCED sent the first time the reader's +queue drains, a cold relay answering SYNCED at once with what it holds and +the upstream's tracks following as live adds, a track added later (a SUBSCRIBE from the listing gets its first group from the cache), a track removed and created again under its name (removal sent before the new add), add and remove churn collapsing for a stalled reader (including an initial -track removed before the reader reaches it, with CAUGHT_UP still arriving), -a stalled reader pinning no more than the live set under churn, a removal +track removed before the reader reaches it, with SYNCED still arriving), a +removal of a track the reader saw landing before SYNCED, a stalled reader +holding no more than the names it saw plus the live set under churn, a removal naming a track that is not live refused, a TRACKS refused on a covering-prefix route, the broadcast ending the listing, a new epoch at the same path not continuing an old listing, a dynamic-only track never created, two downstream listings sharing diff --git a/quest/m1/lite07-finalize.md b/quest/m1/lite07-finalize.md index 0a62eb81d2e..6e3cca9d4f0 100644 --- a/quest/m1/lite07-finalize.md +++ b/quest/m1/lite07-finalize.md @@ -91,7 +91,7 @@ is published; older versions are unchanged. - [Compressed tracks in Rust](/quest/m1/compress/core.md) - TRACK_INFO carries the compress flag and the frame header each compressed frame's inflated size - [Compressed tracks in js/net](/quest/m1/compress/js.md) - JS reads and writes compressed lite-07 tracks instead of refusing them - [TRACKS](/quest/m1/archive/tracks.md) - lite-07 carries the request listing a broadcast's tracks as they appear -- [Announce CAUGHT_UP](/quest/m1/announce-caught-up.md) - lite-07's Announce stream marks its initial set with CAUGHT_UP instead of `Active Count` +- [Announce SYNCED](/quest/m1/announce-synced.md) - lite-07's Announce stream marks its initial set with SYNCED instead of `Active Count` ## Related From 5c0c1e847b09bd3eb90a547afd163725f1d4c660 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:10:51 -0700 Subject: [PATCH 12/20] quest(m1): a refused widen resets the widened listing too Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/subscribe-tracks.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 7c12ba7d1e1..87af89c3c14 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -147,9 +147,14 @@ TRACKS' three events (see its Plan) map as follows: draft-18, UNINTERESTED from draft-19. - Each lite listing filters its request's results to its exact namespace. - A refused update resets every affected listing loudly, so each - re-requests: the new one, and any whose sibling request was cancelled - for the fold, since that request no longer reports adds for it. Never - silently narrow. A request never narrows while a listing under it remains, and is + re-requests. That covers the new one, any whose sibling request was + cancelled for the fold, and the one whose request was being widened: + a REQUEST_ERROR on the update closes that request's stream (draft-22 + section 9.5.1 says so for SUBSCRIBE_TRACKS, and the existing publisher + already treats an update's REQUEST_ERROR as closing the stream on every + draft), so no request survives to keep serving it. The held PUBLISHes + keep their own streams and are adopted again when the re-request lists + them. Never silently narrow. A request never narrows while a listing under it remains, and is cancelled when the last one closes. - Cost: the extra tracks held are only those of broadcasts nested under a listed one, bounded by the `announces` cap below; past it the listings @@ -212,8 +217,8 @@ tracks, and keeps `/a/b`'s held PUBLISHes, and the reverse order shares it unchanged; TRACKS(`/a/b`) and TRACKS(`/a/c`) open two requests and hold no track outside them, and a later TRACKS(`/a`) folds both into one, keeping every held entry and refusing any duplicate PUBLISH; when widening `/a/b` to -`/a` is refused after `/a/c` was cancelled, the `/a` and `/a/c` listings -reset and re-request, while `/a/b` keeps its request; on draft-18, a PUBLISH for +`/a` is refused after `/a/c` was cancelled, the `/a`, `/a/b`, and `/a/c` +listings all reset and re-request; on draft-18, a PUBLISH for a track we already SUBSCRIBEd lists it through that SUBSCRIBE and is refused with DUPLICATE_SUBSCRIPTION; a moq-transport subscriber resuming our paused PUBLISH gets the groups its filter asks for from the cache; a moq-transport subscriber gets From f1fa39572c8c622d5f4b867f17b6fae8fd7be183 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:38:38 -0700 Subject: [PATCH 13/20] quest(m1): SYNCED at first drain or after the snapshot's N events Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-synced.md | 17 ++++++++++++++--- quest/m1/archive/tracks.md | 19 ++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/quest/m1/announce-synced.md b/quest/m1/announce-synced.md index 6d6a1069571..0e3ac04857e 100644 --- a/quest/m1/announce-synced.md +++ b/quest/m1/announce-synced.md @@ -15,13 +15,22 @@ Decided by the maintainer on 2026-10-19 with TRACKS ("We don't need the count at all"), and named SYNCED the same day. A count sent first commits to that many ANNOUNCE_STARTs, so an initial route that ends before a slow reader reaches its start leaves the count unmet, unless the hop retains every -initial announcement per reader. SYNCED is sent once, in order, the first -time the reader's queue drains (its `poll_next` returns Pending). An initial +initial announcement per reader. SYNCED is sent once, in order, at the +first drain of the reader's queue (its `poll_next` returns Pending) or once +the reader has been sent N events, N being the snapshot size it recorded when +it subscribed, so live churn cannot postpone it (maintainer, 2026-10-19). An initial route that comes and goes before then is omitted, collapsing in the reader's pending queue like any other, and a retraction of a route the reader saw may land before SYNCED. Keep the event identical in shape to TRACKS' SYNCED, so one encoding and one consumer rule serve both streams. +- N holds only if the queue delivers in insertion order, so the snapshot + stays ahead of live entries. The origin consumer's queue + (`OriginConsumerState::pending`) is a `BTreeMap` delivered in + lexicographic order, where a live prefix sorting first can precede a + snapshot one indefinitely, and its `owed` set waits on that order. Keep + the keyed collapse but deliver in insertion order. A count of N then + replaces the `owed` set. - Synced means synced with this hop: a relay answers at once with what it holds and never waits on its upstream (relays never block on peers). - The in-process consumers already expose this as synced @@ -46,7 +55,9 @@ Test on mock time, directly and through a relay: SYNCED after the initial routes, sent the first time the reader's queue drains; an initial route ending before a stalled reader reaches it, with SYNCED still arriving; a retraction of a seen route landing before SYNCED; a cold relay answering -SYNCED at once; and a lite-06 peer still getting `Active Count`. +SYNCED at once; a stalled reader under continuous churn of new prefixes, +including ones sorting before the snapshot, getting SYNCED once it has been +sent N events; and a lite-06 peer still getting `Active Count`. Public API: none, if consumers already expose "initial set done"; otherwise the same signal as TRACKS, mirrored in Rust and `@moq/net`. Wire: lite-07-wip diff --git a/quest/m1/archive/tracks.md b/quest/m1/archive/tracks.md index ede63be869c..a316dd1a6f0 100644 --- a/quest/m1/archive/tracks.md +++ b/quest/m1/archive/tracks.md @@ -79,10 +79,16 @@ when a track no longer exists"): properties and no name or stream-specific field; the name is the only thing wrapping it. A lister MAY skip the Track Stream for a track it learned this way, since the properties are the same immutable ones. -- **Synced.** An ordered SYNCED event, sent once, the first time the - reader's queue drains: the first time its `poll_next` returns Pending. - That is the whole rule, decided by the maintainer on 2026-10-19 (renamed - from CAUGHT_UP the same day). A removal may land before SYNCED, and an add +- **Synced.** An ordered SYNCED event, sent once, at the first of two + points: the first time the reader's queue drains (its `poll_next` returns + Pending), or once the reader has been sent N events, where N is the + snapshot size it recorded when it subscribed. Decided by the maintainer on + 2026-10-19 (renamed from CAUGHT_UP the same day), with the bound added so + live churn cannot postpone SYNCED ("It doesn't even need to be the exact + count, it could be something like count * 2 as an upper bound"). N is + enough, with no slack, because the reader's queue delivers in insertion + order: the snapshot entries go in first, and collapsing only removes them + or rewrites them in place, so nothing ever lands ahead of them. A removal may land before SYNCED, and an add and removal the reader has not reached collapse (see churn below), so the lister then holds every track this hop held that is still live. Entries after it are live. There is no @@ -149,7 +155,8 @@ Guidance: can send. Each reader keeps a pending queue keyed by name, as origin announcement consumers do (`OriginConsumerState` in `rs/moq-net/src/model/origin.rs`), decided by the maintainer on - 2026-10-19. A removal for an add still queued for that reader cancels + 2026-10-19. It delivers in insertion order rather than origin's + lexicographic `BTreeMap` order, which the SYNCED bound needs. A removal for an add still queued for that reader cancels both, before or after SYNCED. A removal for an add it already saw is queued once, and a later add of the name joins it, as origin's `UnannounceAnnounce` does. A reader's state is therefore bounded by the @@ -175,6 +182,8 @@ SUBSCRIBE from the listing gets its first group from the cache), a track removed and created again under its name (removal sent before the new add), add and remove churn collapsing for a stalled reader (including an initial track removed before the reader reaches it, with SYNCED still arriving), a +stalled reader under continuous churn of new names getting SYNCED once it +has been sent N events, a removal of a track the reader saw landing before SYNCED, a stalled reader holding no more than the names it saw plus the live set under churn, a removal naming a track that is not live refused, a TRACKS refused on a From f65c874e700372df1bfe57dba97023bf55500203 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:39:58 -0700 Subject: [PATCH 14/20] quest(m1): adopt an existing SUBSCRIBE on every draft, frame index in timed keys Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/README.md | 5 +++-- quest/m1/archive/producer.md | 14 +++++++++----- quest/m1/archive/subscribe-tracks.md | 21 +++++++++++++-------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index e7e754e229f..df31e4ab027 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -40,8 +40,9 @@ archive (per-track `.timeline.z` records named by the catalog's root capped by configuration; a group still missing at the cap is a gap, never a stall. Anything younger is still in the live cache. - **Per-track objects, keyed by position.** Groups coalesce in order into - objects cut by duration, keyed `//-` - (`` for an untimed track, `` percent-encoded as one + objects cut by duration, keyed + `//--` (`` + for an untimed track, `` percent-encoded as one segment), beside a small per-track `.info` holding its timescale and priority. A served track advertises the archive's retention (no limit), not the source's max age, which would expire archived history. The key listing is the index: no index object, diff --git a/quest/m1/archive/producer.md b/quest/m1/archive/producer.md index b79b53d3861..3eaea6a872a 100644 --- a/quest/m1/archive/producer.md +++ b/quest/m1/archive/producer.md @@ -39,9 +39,12 @@ Decided by the maintainer on 2026-10-17 (see the boundaries. Each object preserves group sequences, frame boundaries, timestamps, and payloads. Rejected: whole-broadcast objects per window and size-based cuts. -- **Keys.** `//-` for a timed track and - `//` for an untimed one, zero-padded so keys sort - in content order. `` is the name in the existing canonical percent +- **Keys.** `//--` for a + timed track and `//` for an untimed one, + zero-padded so keys sort in content order. The frame index keeps the + fragments of an oversized timed group unique and ordered, since they share + the group and pts can repeat or go backward in decode order; pts stays for + time lookups. `` is the name in the existing canonical percent encoding (`rs/moq-archive/src/path.rs`, everything but `A-Z a-z 0-9 _ -` escaped), so a name holding `/` stays one segment and no name collides with `.info`; a consumer refuses a non-canonical segment. A sorted LIST answers @@ -70,7 +73,7 @@ Decided by the maintainer on 2026-10-17 (see the continues at the frame after its last stored one: its subscription starts at `Position(last group, stored frame count)`, so a group split across objects or still open at the restart loses nothing. A timed track stores - the rest as a new object keyed by its first frame's pts. An untimed track + the rest as a new object keyed by its first frame's index and pts. An untimed track reads its newest object back and keeps rewriting it in place with the appended frames. A completed group yields no more frames, so this is the same rule whether or not the restart landed mid-group; if the source has @@ -91,7 +94,8 @@ Decided by the maintainer on 2026-10-17 (see the Test on mock time with the in-memory store: a lost group recorded as a gap without stalling later groups, duration and wall-clock cuts, a split oversized -timed group, an open untimed group rewritten in place, an idle track flushed +timed group whose fragments get unique keys in frame order even when pts +repeats or goes backward, an open untimed group rewritten in place, an idle track flushed within the bound, resume after a restart (including mismatched properties, and a restart mid-group for both a split timed group and an open untimed group, with no frame lost or duplicated), a track name holding `/` or diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 87af89c3c14..37e204adc5f 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -91,11 +91,15 @@ TRACKS' three events (see its Plan) map as follows: either way, and readers keep their own lite subscriptions downstream. The costs are that a listed track's upstream subscription lives as long as its entry, paused while unread, and that the existing SUBSCRIBE path adopts a - held PUBLISH as the track's copy instead of opening one. On draft-18, a - PUBLISH for a track we already SUBSCRIBEd is that same duplicate, so the - existing SUBSCRIBE becomes the entry and the PUBLISH is refused with - DUPLICATE_SUBSCRIPTION; for a SUBSCRIBE still pending, end it first, as - section 5.1 requires. Only TRACK_ENDED ("no longer being published") is a removal, + held PUBLISH as the track's copy instead of opening one. Conversely, on + every draft, a PUBLISH for a track we already SUBSCRIBEd (established or + pending) is redundant: the existing SUBSCRIBE becomes the entry and stays + the one copy, and the PUBLISH is refused, with DUPLICATE_SUBSCRIPTION on + draft-18 and UNINTERESTED from draft-19. Draft-18 section 5.1 only asks a + subscriber to end its pending SUBSCRIBE before sending PUBLISH_OK, which a + refusal never sends. A pending SUBSCRIBE's add goes downstream once its + SUBSCRIBE_OK arrives, and a refused one fails the listing loudly, as for a + skipped track. Only TRACK_ENDED ("no longer being published") is a removal, and a later PUBLISH for the name is a new add. Any other status (GOING_AWAY, EXCESSIVE_LOAD, ...) ends only that subscription while the track may still be published, and the peer need not PUBLISH it again, so it @@ -218,9 +222,10 @@ unchanged; TRACKS(`/a/b`) and TRACKS(`/a/c`) open two requests and hold no track outside them, and a later TRACKS(`/a`) folds both into one, keeping every held entry and refusing any duplicate PUBLISH; when widening `/a/b` to `/a` is refused after `/a/c` was cancelled, the `/a`, `/a/b`, and `/a/c` -listings all reset and re-request; on draft-18, a PUBLISH for -a track we already SUBSCRIBEd lists it through that SUBSCRIBE and is refused -with DUPLICATE_SUBSCRIPTION; a moq-transport subscriber resuming our paused +listings all reset and re-request; a PUBLISH for a track we already +SUBSCRIBEd, established or pending, lists it through that SUBSCRIBE and is +refused (DUPLICATE_SUBSCRIPTION on draft-18, UNINTERESTED from draft-19), +leaving one upstream subscription; a moq-transport subscriber resuming our paused PUBLISH gets the groups its filter asks for from the cache; a moq-transport subscriber gets PUBLISH_DONE when a lite track is removed; an unsolicited PUBLISH is refused. From 99585e5a84bf01ed1ddadc1546f49b8e5eaeda12 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:45:27 -0700 Subject: [PATCH 15/20] quest(m1): datagrams arrive from the per-track SUBSCRIBE on Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/producer.md | 4 +++- quest/m1/archive/tracks.md | 14 +++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/quest/m1/archive/producer.md b/quest/m1/archive/producer.md index 3eaea6a872a..0fcba410a7a 100644 --- a/quest/m1/archive/producer.md +++ b/quest/m1/archive/producer.md @@ -5,7 +5,9 @@ `archive::Producer` takes a path prefix and stores every track of every broadcast under it into an `object_store::ObjectStore`, each broadcast under its own key prefix. It holds what the publisher's cache -stops serving: a lost group becomes a gap and never stalls the archive. An +stops serving: a lost group becomes a gap and never stalls the archive. +Datagrams are lossy and never cached, so a track's datagrams are recorded +from when its subscription takes effect; earlier ones are not. An optional DVR mode prunes old objects. It replaces `moq_archive::Writer` and its timelines and recovery machinery. diff --git a/quest/m1/archive/tracks.md b/quest/m1/archive/tracks.md index a316dd1a6f0..c4ef17ad2d3 100644 --- a/quest/m1/archive/tracks.md +++ b/quest/m1/archive/tracks.md @@ -10,7 +10,10 @@ delivery stays pull: nothing is ever pushed into a pulled copy. A listing never creates a track the publisher serves only on request (an any-name `broadcast::Dynamic` handler such as a transcoder). A relay answers many downstream TRACKS for a broadcast from one upstream TRACKS. Rust and JS both -speak it. +speak it. A listed track's groups reach a late SUBSCRIBE from the cache; +its datagrams do not, since datagrams are lossy and never cached, so a +subscriber receives a track's datagrams only from when its SUBSCRIBE takes +effect. ## Plan @@ -32,7 +35,11 @@ track" no longer holds: still holds, so a track's first groups reach a subscriber that learned of the track an RTT late, as long as the publisher retains them (Publisher Max Age). A group already past retention is a gap, which the archive records - anyway. + anyway. Datagrams are outside this: they are never cached + (`rs/moq-net/src/model/track.rs`), so any late subscriber misses the ones + sent before it, and SUBSCRIBE_ALL's pushed feed missed those sent before + it attached too. A datagram track is listed like any other; its datagrams + arrive from the SUBSCRIBE on, and its groups still come from the cache. - **One mechanism cost more than two.** [moq-dev/moq#5931](https://github.com/moq-dev/moq/pull/5931) implemented SUBSCRIBE_ALL and found that a relay has to merge pushed feeds into pulled @@ -178,7 +185,8 @@ request arriving before SYNCED, SYNCED sent the first time the reader's queue drains, a cold relay answering SYNCED at once with what it holds and the upstream's tracks following as live adds, a track added later (a -SUBSCRIBE from the listing gets its first group from the cache), a track +SUBSCRIBE from the listing gets its first group from the cache, and its +datagrams only from the SUBSCRIBE on), a track removed and created again under its name (removal sent before the new add), add and remove churn collapsing for a stalled reader (including an initial track removed before the reader reaches it, with SYNCED still arriving), a From 8cf1ed427d08bb8cdaa9d806018fce84c41fd7fc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 15:56:40 -0700 Subject: [PATCH 16/20] quest(m1): a TRACKS removal promises only that the track left the listing Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/tracks.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/quest/m1/archive/tracks.md b/quest/m1/archive/tracks.md index c4ef17ad2d3..df0264ee119 100644 --- a/quest/m1/archive/tracks.md +++ b/quest/m1/archive/tracks.md @@ -108,9 +108,10 @@ when a track no longer exists"): 2026-10-19: a relay answers at once with what it currently holds and never waits on its upstream, since relays never block on peers. A cold relay may report zero; the upstream's tracks then arrive as live adds. -- **Track removed.** Names the track, sent when the publisher no longer - serves it (it left the broadcast's track table), so a SUBSCRIBE for it - would now be refused. There are no entry IDs: a name is unique among a +- **Track removed.** Names the track, sent when it leaves the broadcast's + track table, the enumerable inventory. A `broadcast::Dynamic` handler may + still serve that name on demand, so a later SUBSCRIBE may still succeed; + removal promises only that the track is no longer listed. There are no entry IDs: a name is unique among a broadcast's live tracks, so the name identifies the entry, and a track created again under it is a later add of the same track: within one broadcast instance a name always means the same content, so its group From a48648879df444a0cee3998525e20ca3027d13e2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 16:38:45 -0700 Subject: [PATCH 17/20] quest(m1): SUBSCRIBE_TRACKS never widens in place Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/subscribe-tracks.md | 74 +++++++++++++--------------- 1 file changed, 35 insertions(+), 39 deletions(-) diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index 37e204adc5f..f70a4e02507 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -121,7 +121,8 @@ TRACKS' three events (see its Plan) map as follows: credit the upstream may have run short of when it skipped, so an upstream short on capacity fails and retries the listing rather than leave an entry that is never removed. -- **One SUBSCRIBE_TRACKS per nesting chain, widened in place.** +- **One SUBSCRIBE_TRACKS per nesting chain, never widened in place.** + Decided by the maintainer on 2026-10-19. SUBSCRIBE_TRACKS matches every namespace under its prefix, and a session may not hold two whose prefixes "share a common prefix" (PREFIX_OVERLAP). Read that strictly: two prefixes overlap only when one is a prefix of the @@ -133,40 +134,36 @@ TRACKS' three events (see its Plan) map as follows: forbids siblings outright. Asking for the exact namespace does not avoid the rule, since `/a` is a prefix of `/a/b`, so: - Siblings get separate SUBSCRIBE_TRACKS requests. - - Nested listings share one request at the broader of the two, never a - common ancestor nobody listed. TRACKS(`/a`) after TRACKS(`/a/b`) widens - the request in place with REQUEST_UPDATE carrying TRACK_NAMESPACE_PREFIX - (draft-18 on); the overlap rule checks only other requests, so updating - our own is legal. The update leaves existing subscriptions alone - (draft-22 section 9.5.2), so the held PUBLISHes stay; the newly covered - tracks arrive as PUBLISHes, and PUBLISH_SKIPPED suffixes are read against - the updated prefix. - - A broader listing over several sibling requests, such as TRACKS(`/a`) - over `/a/b` and `/a/c`, folds them. The widened prefix may not overlap - another request, so cancel the others first and then widen one. The - cancel ends only those requests, and the PUBLISH subscriptions they - produced keep their own streams (draft-18 section 6.1), so their entries - stay. If the widened request PUBLISHes a track we already hold, keep the - held one as the entry and refuse the new one: DUPLICATE_SUBSCRIPTION on - draft-18, UNINTERESTED from draft-19. + - A nested listing that arrives while a broader request covers it reuses + that request and filters locally. TRACKS(`/a/b`) after TRACKS(`/a`) + sends nothing. + - A broader listing that arrives while nested requests exist cancels them, + then opens the broader request. TRACKS(`/a`) over `/a/b` and `/a/c` + cancels both and sends SUBSCRIBE_TRACKS(`/a`). The cancel ends only + those requests, and the PUBLISH subscriptions they produced keep their + own streams (draft-18 section 6.1), so their entries stay. When the new + request PUBLISHes a track we already hold, keep the held one as the + entry and refuse the new one: DUPLICATE_SUBSCRIPTION on draft-18, + UNINTERESTED from draft-19. + - A track created and removed between that cancel and the open is missed, + which the listing contract allows: a listing reports current state, not + history, and a lagging lite reader misses a short-lived track the same + way, since a removal for a still-queued add cancels both. - Each lite listing filters its request's results to its exact namespace. - - A refused update resets every affected listing loudly, so each - re-requests. That covers the new one, any whose sibling request was - cancelled for the fold, and the one whose request was being widened: - a REQUEST_ERROR on the update closes that request's stream (draft-22 - section 9.5.1 says so for SUBSCRIBE_TRACKS, and the existing publisher - already treats an update's REQUEST_ERROR as closing the stream on every - draft), so no request survives to keep serving it. The held PUBLISHes - keep their own streams and are adopted again when the re-request lists - them. Never silently narrow. A request never narrows while a listing under it remains, and is - cancelled when the last one closes. + - If the broader request is refused, every listing it was meant to serve, + the new one and each nested one whose request was cancelled, resets + loudly and re-requests. The held PUBLISHes keep their own streams and + are adopted again when a re-request lists them. A request never narrows + while a listing under it remains, and is cancelled when the last one + closes. - Cost: the extra tracks held are only those of broadcasts nested under a listed one, bounded by the `announces` cap below; past it the listings reset as for any other overflow. - - Rejected: cancelling the narrow request and sending a broader one, since - the peer would PUBLISH the held tracks again, a duplicate on draft-18; - and one request per session at the common ancestor, which for `/a/b` - and `/c/d` is the root and holds every track the peer has. + - Rejected: widening a request in place with REQUEST_UPDATE carrying + TRACK_NAMESPACE_PREFIX, which avoids the cancel-to-open gap but needs + its own refused-update rules for a gap the listing contract already + allows; and one request per session at the common ancestor, which for + `/a/b` and `/c/d` is the root and holds every track the peer has. - **Push inventory only.** An IETF relay lists the tracks it has been PUBLISHed or already holds, not what an upstream would serve on SUBSCRIBE. A track served only on request is unlisted, which matches TRACKS' rule. @@ -215,14 +212,13 @@ pauses it and keeps the entry, a PUBLISH_DONE with TRACK_ENDED removes the entry another status resets the listing; a PUBLISH_SKIPPED (PUBLISH_BLOCKED on draft-18) lists the track once its paused SUBSCRIBE answers, resuming it gets the data, a TRACK_ENDED on that paused SUBSCRIBE removes it, -and a refused paused SUBSCRIBE resets the listing; TRACKS(`/a/b`) then -TRACKS(`/a`) on one session widens the one SUBSCRIBE_TRACKS, lists `/a`'s -tracks, and keeps `/a/b`'s held PUBLISHes, and the reverse order shares it -unchanged; TRACKS(`/a/b`) and TRACKS(`/a/c`) open two requests and hold no -track outside them, and a later TRACKS(`/a`) folds both into one, keeping -every held entry and refusing any duplicate PUBLISH; when widening `/a/b` to -`/a` is refused after `/a/c` was cancelled, the `/a`, `/a/b`, and `/a/c` -listings all reset and re-request; a PUBLISH for a track we already +and a refused paused SUBSCRIBE resets the listing; TRACKS(`/a/b`) after +TRACKS(`/a`) on one session reuses the `/a` request, sends nothing, and +lists only `/a/b`'s tracks; TRACKS(`/a/b`) and TRACKS(`/a/c`) open two +requests and hold no track outside them, and a later TRACKS(`/a`) cancels +both before opening SUBSCRIBE_TRACKS(`/a`), keeping every held entry and +refusing any duplicate PUBLISH; when that SUBSCRIBE_TRACKS(`/a`) is refused, +the `/a`, `/a/b`, and `/a/c` listings all reset and re-request; a PUBLISH for a track we already SUBSCRIBEd, established or pending, lists it through that SUBSCRIBE and is refused (DUPLICATE_SUBSCRIPTION on draft-18, UNINTERESTED from draft-19), leaving one upstream subscription; a moq-transport subscriber resuming our paused From f817fdad17202050522575f5d377b8602e26656c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 16:47:40 -0700 Subject: [PATCH 18/20] quest(m1): archive keeps datagram form and end markers, cancels close before a broader listing Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/README.md | 4 +++- quest/m1/archive/consumer.md | 15 ++++++++++++-- quest/m1/archive/producer.md | 30 +++++++++++++++++++++++++--- quest/m1/archive/subscribe-tracks.md | 17 +++++++++++++++- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index df31e4ab027..9fec25e0b4a 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -44,7 +44,9 @@ archive (per-track `.timeline.z` records named by the catalog's root `//--` (`` for an untimed track, `` percent-encoded as one segment), beside a small per-track `.info` holding its timescale and - priority. A served track advertises the archive's retention (no limit), not + priority. Each stored sequence keeps its form, group or datagram. A clean + end writes an `.end` marker per track and per broadcast; without one an + archive reads as live. A served track advertises the archive's retention (no limit), not the source's max age, which would expire archived history. The key listing is the index: no index object, manifest, or timeline. The application picks each broadcast's key prefix (per path and epoch, say), so a resume in one epoch continues from the diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md index 2e1491a67fc..f13e6b61728 100644 --- a/quest/m1/archive/consumer.md +++ b/quest/m1/archive/consumer.md @@ -35,6 +35,15 @@ Decided by the maintainer on 2026-10-17 (see the quest's union and `enabled: false` handling are dropped with it. - The served broadcast takes its epoch from the caller or mints one; it never claims the source's epoch, since it is served at another path. +- Each stored sequence replays in its recorded form: a group as a group, a + datagram as a datagram (`track::Producer::insert_datagram`), so the replay + keeps the source's lossy, uncached datagram contract. +- A track's `.end` finishes it, mirroring `moq_archive::Reader::finish` + today: refreshing stops, the track is finished at the recorded end + (`track::Producer::finish_at`), and committed groups stay servable. The + broadcast's `.end` stops the search for added tracks. Without markers the + archive is live and followed through refresh, which is how a producer + that crashed reads. This replaces the caller-driven `Reader::finish`. Guidance: @@ -56,8 +65,10 @@ Test with the in-memory store: a round trip preserving structure and payloads byte for byte, a gap, a pruned start, a growing archive followed through refresh (including two tracks where a new key sorts before the other track's cursor, and a track added mid-recording), history older than the source's max -age still served, an untimed track, and an empty or unrecognized prefix -refused loudly. +age still served, an untimed track, an interleaved group and datagram track +replayed in its original forms, a completed archive whose replay ends at +each track's recorded end (including mid-group), a growing archive with no +markers staying open, and an empty or unrecognized prefix refused loudly. Public API: `archive::Consumer` replaces `Reader`. Wire: none. diff --git a/quest/m1/archive/producer.md b/quest/m1/archive/producer.md index 0fcba410a7a..5f842fb22a9 100644 --- a/quest/m1/archive/producer.md +++ b/quest/m1/archive/producer.md @@ -41,6 +41,14 @@ Decided by the maintainer on 2026-10-17 (see the boundaries. Each object preserves group sequences, frame boundaries, timestamps, and payloads. Rejected: whole-broadcast objects per window and size-based cuts. +- **Datagrams keep their form.** A track's groups and datagrams share one + sequence space, so each stored sequence records its forwarding form, group + or datagram, and a datagram is stored as its one payload and timestamp in + sequence order beside the groups. The consumer replays a datagram as a + datagram, never as a cached one-frame group, so the replay keeps the + source's lossy contract. A datagram is never split and counts as one + sequence for keys, cuts, and resume. Datagrams sent before the track's + SUBSCRIBE took effect are not recorded, as above. - **Keys.** `//--` for a timed track and `//` for an untimed one, zero-padded so keys sort in content order. The frame index keeps the @@ -49,7 +57,7 @@ Decided by the maintainer on 2026-10-17 (see the time lookups. `` is the name in the existing canonical percent encoding (`rs/moq-archive/src/path.rs`, everything but `A-Z a-z 0-9 _ -` escaped), so a name holding `/` stays one segment and no name collides with - `.info`; a consumer refuses a non-canonical segment. A sorted LIST answers + `.info` or `.end`; a consumer refuses a non-canonical segment. A sorted LIST answers group and time lookups; there is no index object or manifest. Rejected for untimed: `-`, since an untimed group is never split. @@ -66,6 +74,18 @@ Decided by the maintainer on 2026-10-17 (see the (timestamp age measures a group against the newest one), so the consumer advertises its own retention instead. Rejected: properties only in each object's header. +- **Completion markers.** When a track ends cleanly, at its declared end + (`track::Subscriber::poll_finished`) with every frame below it stored, the + producer writes a small `//.end` holding that exclusive end + as a `Position` (group and frame), the shape `track::Producer::finish_at` + takes, so a mid-group end survives. Once the broadcast's listing has ended + with the instance and every track it listed has its `.end`, the producer + writes `/.end`, which says no track will be added. A track that + ends with an error gets no marker, and neither does its broadcast. The + consumer finishes on reading a marker and otherwise treats the archive as + live. A crashed producer leaves no marker, so its archive reads as still + growing. A resume skips a track that already has its `.end`, and DVR + pruning never deletes a marker. - **Bounded flush** (folded from the idle-flush quest): an object holding a frame is stored within a bounded wall-clock delay of that frame's arrival, even when its track goes idle. The bound covers closing the object, not @@ -102,8 +122,12 @@ within the bound, resume after a restart (including mismatched properties, and a restart mid-group for both a split timed group and an open untimed group, with no frame lost or duplicated), a track name holding `/` or starting with `.` round-tripping as one segment, -DVR pruning with grace, a track added mid-recording, and two broadcasts under -the prefix landing under their own key prefixes. +DVR pruning with grace, a track added mid-recording, two broadcasts under +the prefix landing under their own key prefixes, a track interleaving groups +and datagrams stored with each sequence's form, a clean end writing each +track's `.end` (including a mid-group end) and then the broadcast's, and a +track ending with an error or a producer stopped mid-recording writing no +marker. Public API: `archive::Producer` and its config replace `Writer`, `Control`, and the timeline-driven types. Wire: none. diff --git a/quest/m1/archive/subscribe-tracks.md b/quest/m1/archive/subscribe-tracks.md index f70a4e02507..7545c9207e0 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -145,6 +145,20 @@ TRACKS' three events (see its Plan) map as follows: request PUBLISHes a track we already hold, keep the held one as the entry and refuse the new one: DUPLICATE_SUBSCRIPTION on draft-18, UNINTERESTED from draft-19. + - The broader request opens only once every cancelled request's stream + has closed on the peer's side, since streams are not ordered against + each other and a peer that reads the new request first answers + PREFIX_OVERLAP. Cancel by closing only our sending half (FIN on + draft-18, where a FIN cancels; RESET_STREAM from draft-19, as draft-22 + section 6.4.2.3 describes), then keep reading the response half until + the peer FINs or resets it. Delay STOP_SENDING: QUIC answers it with an + automatic RESET_STREAM before the peer's application has seen the + cancel, while the peer's own close of its half comes from its + application once the request is gone (the existing publisher ends the + request on `poll_cancel` in `rs/moq-net/src/ietf/request_stream.rs`). + A peer that never closes its half stalls only the broader listing. + Should PREFIX_OVERLAP still come back, it is a refusal like any other: + the listings reset loudly below, with no automatic retry. - A track created and removed between that cancel and the open is missed, which the listing contract allows: a listing reports current state, not history, and a lagging lite reader misses a short-lived track the same @@ -216,7 +230,8 @@ and a refused paused SUBSCRIBE resets the listing; TRACKS(`/a/b`) after TRACKS(`/a`) on one session reuses the `/a` request, sends nothing, and lists only `/a/b`'s tracks; TRACKS(`/a/b`) and TRACKS(`/a/c`) open two requests and hold no track outside them, and a later TRACKS(`/a`) cancels -both before opening SUBSCRIBE_TRACKS(`/a`), keeping every held entry and +both and opens SUBSCRIBE_TRACKS(`/a`) only after the peer closes both +cancelled streams, even when it is slow to, keeping every held entry and refusing any duplicate PUBLISH; when that SUBSCRIBE_TRACKS(`/a`) is refused, the `/a`, `/a/b`, and `/a/c` listings all reset and re-request; a PUBLISH for a track we already SUBSCRIBEd, established or pending, lists it through that SUBSCRIBE and is From 0d95fd5e020056e9a92edc77f44ce847b2e676b8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 16:55:09 -0700 Subject: [PATCH 19/20] quest(m1): archive markers record errors and sort where refresh finds them Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/README.md | 7 ++++--- quest/m1/archive/consumer.md | 21 +++++++++++++------- quest/m1/archive/producer.md | 37 +++++++++++++++++++++++------------- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 9fec25e0b4a..3778ec564f1 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -44,9 +44,10 @@ archive (per-track `.timeline.z` records named by the catalog's root `//--` (`` for an untimed track, `` percent-encoded as one segment), beside a small per-track `.info` holding its timescale and - priority. Each stored sequence keeps its form, group or datagram. A clean - end writes an `.end` marker per track and per broadcast; without one an - archive reads as live. A served track advertises the archive's retention (no limit), not + priority. Each stored sequence keeps its form, group or datagram. A + track's `end` records how it ended, clean or with its error code, and a + broadcast's `.end` follows once every listed track has one; without them + an archive reads as live. A served track advertises the archive's retention (no limit), not the source's max age, which would expire archived history. The key listing is the index: no index object, manifest, or timeline. The application picks each broadcast's key prefix (per path and epoch, say), so a resume in one epoch continues from the diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md index f13e6b61728..f7ccf922e54 100644 --- a/quest/m1/archive/consumer.md +++ b/quest/m1/archive/consumer.md @@ -38,12 +38,18 @@ Decided by the maintainer on 2026-10-17 (see the - Each stored sequence replays in its recorded form: a group as a group, a datagram as a datagram (`track::Producer::insert_datagram`), so the replay keeps the source's lossy, uncached datagram contract. -- A track's `.end` finishes it, mirroring `moq_archive::Reader::finish` - today: refreshing stops, the track is finished at the recorded end - (`track::Producer::finish_at`), and committed groups stay servable. The - broadcast's `.end` stops the search for added tracks. Without markers the +- A track's `end`, reached by its `StartAfter` refresh since it sorts after + every object key, ends it as recorded. A clean end mirrors + `moq_archive::Reader::finish` today: refreshing stops, the track is + finished at the recorded end (`track::Producer::finish_at`), and + committed groups stay servable. An error end serves the stored groups and + then aborts the track with the recorded code (`StreamError::from_code`), + failing loud as the source did rather than reading as live. The + broadcast's `.end`, seen by the delimiter LIST, stops the search for + added tracks. Without markers the archive is live and followed through refresh, which is how a producer - that crashed reads. This replaces the caller-driven `Reader::finish`. + that crashed or was stopped reads. This replaces the caller-driven + `Reader::finish`. Guidance: @@ -67,8 +73,9 @@ refresh (including two tracks where a new key sorts before the other track's cursor, and a track added mid-recording), history older than the source's max age still served, an untimed track, an interleaved group and datagram track replayed in its original forms, a completed archive whose replay ends at -each track's recorded end (including mid-group), a growing archive with no -markers staying open, and an empty or unrecognized prefix refused loudly. +each track's recorded end (including mid-group), an errored track +replaying its stored groups and then the recorded error, a growing archive +with no markers staying open, and an empty or unrecognized prefix refused loudly. Public API: `archive::Consumer` replaces `Reader`. Wire: none. diff --git a/quest/m1/archive/producer.md b/quest/m1/archive/producer.md index 5f842fb22a9..b4b3c644ecc 100644 --- a/quest/m1/archive/producer.md +++ b/quest/m1/archive/producer.md @@ -74,17 +74,25 @@ Decided by the maintainer on 2026-10-17 (see the (timestamp age measures a group against the newest one), so the consumer advertises its own retention instead. Rejected: properties only in each object's header. -- **Completion markers.** When a track ends cleanly, at its declared end - (`track::Subscriber::poll_finished`) with every frame below it stored, the - producer writes a small `//.end` holding that exclusive end - as a `Position` (group and frame), the shape `track::Producer::finish_at` - takes, so a mid-group end survives. Once the broadcast's listing has ended - with the instance and every track it listed has its `.end`, the producer - writes `/.end`, which says no track will be added. A track that - ends with an error gets no marker, and neither does its broadcast. The - consumer finishes on reading a marker and otherwise treats the archive as - live. A crashed producer leaves no marker, so its archive reads as still - growing. A resume skips a track that already has its `.end`, and DVR +- **Completion markers**, decided by the maintainer on 2026-10-19 ("record + the error"). Once a track has ended, its ordered groups and its datagrams + (`poll_recv_datagram`) both having reached their end so no received + datagram is lost, and every frame and datagram it delivered is stored, the + producer writes a small `//end` recording how it ended: a clean end at its declared exclusive `Position` + (`track::Subscriber::poll_finished`; group and frame, the shape + `track::Producer::finish_at` takes, so a mid-group end survives), or the + stream error code it ended with (`StreamError::to_code`). Once the + broadcast's listing has ended with the instance and every track it listed + has a marker, clean or errored, the producer writes `/.end`, which + says no track will be added. The names are chosen for the consumer's + refresh: a track's `end` sorts after its zero-padded numeric keys, so + the track's `StartAfter` LIST reaches it with no extra request, and it can + never be an object key; the broadcast's `.end` sits beside the track + segments, where the delimiter LIST that finds added tracks sees it, and + no canonical track name starts with `.`. The consumer ends each track as + its marker says and otherwise treats the archive as live. Only a crash, or a producer + stopped before the end, leaves no marker, so its archive reads as still + growing. A resume skips a track that already has its `end`, and DVR pruning never deletes a marker. - **Bounded flush** (folded from the idle-flush quest): an object holding a frame is stored within a bounded wall-clock delay of that frame's arrival, @@ -125,8 +133,11 @@ starting with `.` round-tripping as one segment, DVR pruning with grace, a track added mid-recording, two broadcasts under the prefix landing under their own key prefixes, a track interleaving groups and datagrams stored with each sequence's form, a clean end writing each -track's `.end` (including a mid-group end) and then the broadcast's, and a -track ending with an error or a producer stopped mid-recording writing no +track's `end` (including a mid-group end) and then the broadcast's +`.end`, a track whose groups and datagrams interleave and end at once +storing every received datagram before its `end`, a +track ending with an error writing an error marker while its broadcast +still gets `.end`, and a producer stopped mid-recording writing no marker. Public API: `archive::Producer` and its config replace `Writer`, `Control`, From bd5c3faf0dee0670b16e8625bb2c8c4760b247e5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 17:00:06 -0700 Subject: [PATCH 20/20] quest(m1): archive markers wait for the broadcast listing to end Co-Authored-By: Claude Opus 5.5 --- quest/m1/archive/README.md | 6 ++--- quest/m1/archive/consumer.md | 7 ++++-- quest/m1/archive/producer.md | 45 +++++++++++++++++++++--------------- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 3778ec564f1..d2d61379328 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -45,9 +45,9 @@ archive (per-track `.timeline.z` records named by the catalog's root for an untimed track, `` percent-encoded as one segment), beside a small per-track `.info` holding its timescale and priority. Each stored sequence keeps its form, group or datagram. A - track's `end` records how it ended, clean or with its error code, and a - broadcast's `.end` follows once every listed track has one; without them - an archive reads as live. A served track advertises the archive's retention (no limit), not + track's `end` records how it ended, clean or with its error code, written + with the broadcast's `.end` once the broadcast's listing ends; without + them an archive reads as live. A served track advertises the archive's retention (no limit), not the source's max age, which would expire archived history. The key listing is the index: no index object, manifest, or timeline. The application picks each broadcast's key prefix (per path and epoch, say), so a resume in one epoch continues from the diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md index f7ccf922e54..a71663eee6f 100644 --- a/quest/m1/archive/consumer.md +++ b/quest/m1/archive/consumer.md @@ -46,7 +46,9 @@ Decided by the maintainer on 2026-10-17 (see the then aborts the track with the recorded code (`StreamError::from_code`), failing loud as the source did rather than reading as live. The broadcast's `.end`, seen by the delimiter LIST, stops the search for - added tracks. Without markers the + added tracks. The producer writes every marker only once the broadcast + has ended, so a live consumer finishes tracks only then; until then an + ended track just stops growing, and a re-added one grows again. Without markers the archive is live and followed through refresh, which is how a producer that crashed or was stopped reads. This replaces the caller-driven `Reader::finish`. @@ -74,7 +76,8 @@ cursor, and a track added mid-recording), history older than the source's max age still served, an untimed track, an interleaved group and datagram track replayed in its original forms, a completed archive whose replay ends at each track's recorded end (including mid-group), an errored track -replaying its stored groups and then the recorded error, a growing archive +replaying its stored groups and then the recorded error, a track removed +and re-added under its name replaying both runs in sequence, a growing archive with no markers staying open, and an empty or unrecognized prefix refused loudly. Public API: `archive::Consumer` replaces `Reader`. Wire: none. diff --git a/quest/m1/archive/producer.md b/quest/m1/archive/producer.md index b4b3c644ecc..452e39b5c1b 100644 --- a/quest/m1/archive/producer.md +++ b/quest/m1/archive/producer.md @@ -75,16 +75,19 @@ Decided by the maintainer on 2026-10-17 (see the advertises its own retention instead. Rejected: properties only in each object's header. - **Completion markers**, decided by the maintainer on 2026-10-19 ("record - the error"). Once a track has ended, its ordered groups and its datagrams - (`poll_recv_datagram`) both having reached their end so no received - datagram is lost, and every frame and datagram it delivered is stored, the - producer writes a small `//end` recording how it ended: a clean end at its declared exclusive `Position` - (`track::Subscriber::poll_finished`; group and frame, the shape - `track::Producer::finish_at` takes, so a mid-group end survives), or the - stream error code it ended with (`StreamError::to_code`). Once the - broadcast's listing has ended with the instance and every track it listed - has a marker, clean or errored, the producer writes `/.end`, which - says no track will be added. The names are chosen for the consumer's + the error"). A track has ended once its ordered groups and its datagrams + (`poll_recv_datagram`) have both reached their end, so no received + datagram is lost. How it ended is decided then: a clean end at its + declared exclusive `Position` (`track::Subscriber::poll_finished`; group + and frame, the shape `track::Producer::finish_at` takes, so a mid-group + end survives), or the stream error code it ended with + (`StreamError::to_code`). The markers wait for the broadcast's listing to + end with the instance, since until then a track removed and re-added + under its name continues its sequences and a written marker would be + wrong. Then, with every frame and datagram stored, the producer writes + each track's small `//end`, recording how its last run + ended, followed by `/.end`, which says no track will be added. + Until then an ended track just stops growing. The names are chosen for the consumer's refresh: a track's `end` sorts after its zero-padded numeric keys, so the track's `StartAfter` LIST reaches it with no extra request, and it can never be an object key; the broadcast's `.end` sits beside the track @@ -92,7 +95,11 @@ Decided by the maintainer on 2026-10-17 (see the no canonical track name starts with `.`. The consumer ends each track as its marker says and otherwise treats the archive as live. Only a crash, or a producer stopped before the end, leaves no marker, so its archive reads as still - growing. A resume skips a track that already has its `end`, and DVR + growing. A producer resuming mid-broadcast follows every stored track as + live, including one that ended before the restart; it writes markers only + for tracks whose end it saw, so a stored track that ended unseen leaves + the broadcast without `.end`, as a stop before the end does. A resume + under a broadcast that already has its `.end` records nothing. DVR pruning never deletes a marker. - **Bounded flush** (folded from the idle-flush quest): an object holding a frame is stored within a bounded wall-clock delay of that frame's arrival, @@ -132,13 +139,15 @@ group, with no frame lost or duplicated), a track name holding `/` or starting with `.` round-tripping as one segment, DVR pruning with grace, a track added mid-recording, two broadcasts under the prefix landing under their own key prefixes, a track interleaving groups -and datagrams stored with each sequence's form, a clean end writing each -track's `end` (including a mid-group end) and then the broadcast's -`.end`, a track whose groups and datagrams interleave and end at once -storing every received datagram before its `end`, a -track ending with an error writing an error marker while its broadcast -still gets `.end`, and a producer stopped mid-recording writing no -marker. +and datagrams stored with each sequence's form, a clean end writing no +marker until the listing ends and then each track's `end` (including a +mid-group end) followed by the broadcast's `.end`, a track removed and +re-added under its name before the broadcast ends whose replay holds both +runs in sequence and whose marker records the second run's end, a track +whose groups and datagrams interleave and end at once storing every +received datagram before its `end`, a track ending with an error writing an +error marker while its broadcast still gets `.end`, and a producer stopped +mid-recording writing no marker. Public API: `archive::Producer` and its config replace `Writer`, `Control`, and the timeline-driven types. Wire: none.