diff --git a/quest/m1/README.md b/quest/m1/README.md index d49835a8183..2ad386ddfb6 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -65,6 +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 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 @@ -80,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 - [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 - [IETF takeover](/quest/m1/ietf-takeover.md) - carry the shared takeover contract through the negotiated IETF epoch extension @@ -104,6 +105,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 - [Rust resumed reads](/quest/m1/resume-ordered.md) - a Rust reader takes the lowest group across route copies in both orders, as JS does - [Resumed close](/quest/m1/resume-closed.md) - a resumed JS track keeps its declared end across copies, and its `closed` and `largest()` settle on a clean end - [Resumed in-flight groups](/quest/m1/resume-in-flight.md) - a replaced route copy stays while a group below its bound is in flight or it backs a handed-out group late, and no longer than the reader's budget diff --git a/quest/m1/announce-synced.md b/quest/m1/announce-synced.md new file mode 100644 index 00000000000..0e3ac04857e --- /dev/null +++ b/quest/m1/announce-synced.md @@ -0,0 +1,68 @@ +# [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, 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 + (`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; 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 +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 be321e5fc98..d2d61379328 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,23 +25,29 @@ 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 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 + priority. Each stored sequence keeps its form, group or datagram. A + 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 @@ -70,17 +77,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, 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 -- [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..a71663eee6f 100644 --- a/quest/m1/archive/consumer.md +++ b/quest/m1/archive/consumer.md @@ -28,13 +28,30 @@ 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 quest's union and `enabled: false` handling are dropped with it. - The served broadcast takes its epoch from the caller or mints one; it never claims the source's epoch, since it is served at another path. +- Each stored sequence replays in its recorded form: a group as a group, a + datagram as a datagram (`track::Producer::insert_datagram`), so the replay + keeps the source's lossy, uncached datagram contract. +- A track's `end`, reached by its `StartAfter` refresh since it sorts after + every object key, ends it as recorded. A clean end mirrors + `moq_archive::Reader::finish` today: refreshing stops, the track is + finished at the recorded end (`track::Producer::finish_at`), and + committed groups stay servable. An error end serves the stored groups and + then aborts the track with the recorded code (`StreamError::from_code`), + failing loud as the source did rather than reading as live. The + broadcast's `.end`, seen by the delimiter LIST, stops the search for + added tracks. The producer writes every marker only once the broadcast + has ended, so a live consumer finishes tracks only then; until then an + ended track just stops growing, and a re-added one grows again. Without markers the + archive is live and followed through refresh, which is how a producer + that crashed or was stopped reads. This replaces the caller-driven + `Reader::finish`. Guidance: @@ -56,8 +73,12 @@ 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), an errored track +replaying its stored groups and then the recorded error, a track removed +and re-added under its name replaying both runs in sequence, a growing archive +with no markers staying open, and an empty or unrecognized prefix refused loudly. Public API: `archive::Consumer` replaces `Reader`. Wire: none. diff --git a/quest/m1/archive/producer.md b/quest/m1/archive/producer.md index 38bf695961b..452e39b5c1b 100644 --- a/quest/m1/archive/producer.md +++ b/quest/m1/archive/producer.md @@ -2,10 +2,12 @@ ## 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 +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. @@ -14,11 +16,21 @@ 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 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 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 + 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, @@ -29,12 +41,23 @@ 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 +- **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 + 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 + `.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. @@ -51,6 +74,33 @@ 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**, decided by the maintainer on 2026-10-19 ("record + 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 + 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 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, even when its track goes idle. The bound covers closing the object, not @@ -60,13 +110,13 @@ 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 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: @@ -81,20 +131,30 @@ 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 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 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. ## 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..7545c9207e0 100644 --- a/quest/m1/archive/subscribe-tracks.md +++ b/quest/m1/archive/subscribe-tracks.md @@ -1,41 +1,250 @@ -# [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 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. 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 data only once we resume it with the filter our own +SUBSCRIBE would send, so it is a pull, not that merge. ## 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 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 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 | +| 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 + 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. 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. +- **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. +- **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. 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 + 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. 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 + 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; 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. 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 + 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. +- **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 + 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. + - 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. + - 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 + way, since a removal for a still-queued add cancels both. + - Each lite listing filters its request's results to its exact namespace. + - 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: 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. +- **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. + +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, 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. +- 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: - 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. +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 (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), 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 +gets the data, a TRACK_ENDED on that paused SUBSCRIBE removes it, +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 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 +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. -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, 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 -- [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..df0264ee119 --- /dev/null +++ b/quest/m1/archive/tracks.md @@ -0,0 +1,211 @@ +# [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, 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 +`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. 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 + +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. 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 + 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 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 +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 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, +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; 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, 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 + 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 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. +- **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 + 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). + +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 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. +- 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 publisher can churn, adding and removing tracks as fast as it + 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. 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 + 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 + 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 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, 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 +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 +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. + +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..6e3cca9d4f0 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,8 @@ 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 +- [Announce SYNCED](/quest/m1/announce-synced.md) - lite-07's Announce stream marks its initial set with SYNCED instead of `Active Count` ## 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