diff --git a/quest/m1/README.md b/quest/m1/README.md index 93f3222a7d9..fe13d746b39 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -78,7 +78,7 @@ lands before the cut. - [IETF fill timeout](/quest/m1/ietf-fill-timeout.md) - native FILL_TIMEOUT bounds upstream waiting on drafts 18-22, alongside local content-age enforcement - [Browser follows a renamed track](/quest/m1/browser-rename-check.md) - a real browser keeps playing when `@moq/publish` renames a track after a codec change, while size-only changes keep their identity - [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 -- [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS; preserve published format boundaries while completing replay and DVR correctness +- [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 - [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 diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 6e7b3a00e27..be321e5fc98 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -1,171 +1,91 @@ -# Timeline-indexed MoQ archives +# Broadcast archives ## Goal -`moq-archive` records explicitly selected tracks from a -`broadcast::Consumer` into any `object_store::ObjectStore`, then serves those -groups through a caller-provided `broadcast::Producer`. The archive timeline is -both its seek index and its availability contract: every range it advertises -has already been stored. +`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 +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. -The same archive supports MoQ FETCH, DVR, and ordinary HLS derived on demand. -After normal catalog discovery, an HLS media playlist is generated by -downloading only the timeline, never media objects. - -The catalog's root `archive` entry and the `moq-archive` object layout have -landed on main; the line may still reshape both. +The catalog carries no archive pointer. Timelines survive only as an opt-in +`hls` field for HLS compatibility. ## Plan -### Compatibility - -The release branch and `moq-archive-v0.0.14` carry format v1; main carries -v2. Published recordings therefore exist as a compatibility obligation, even -without evidence of deployed archives. Preserve version recognition and -explicit refusal of unsupported versions. Before reshaping v2, check whether -it has published; a published format changes through a new version. The old -blanket permission to break recordings in place no longer applies. - -### Landed - -Every track has its own timeline (`rs/moq-mux/src/timeline.rs`): - -- A `Segmenter` builds one track's `{ sequence, pts, duration, start, end }` - records (`rs/hang/src/timeline.rs`) from its frame reports: a record ends at - the first group start past a minimum, a sparse track (zero minimum) records - each group as it finishes, a group open past its internal safety ceiling - splits between frames, and `cut(pts)` adds application boundaries. -- A `Producer` publishes records onto a timeline track, a - `moq_json::window::Producer` whose `pop` trims the oldest and `flush` closes - the open group. `Timelines` pairs them for live publishing and names each - track's timeline in the catalog's root `archive` entry. -- `moq-hls` renders playlists from the timelines alone and FETCHes media per - HTTP request. Segment boundaries come from a reference rendition's records; - other renditions resolve each segment against their own timelines - (`rs/moq-hls/src/export/spans.rs`). A clean reference timeline finish ends - every window with `EXT-X-ENDLIST`. -- The same exporter serves a recording replayed through `moq_archive::Reader` - with no archive-specific code (`rs/moq-hls/src/export/archive_tests.rs`): - playlists read only the timelines, and a segment GETs only its rendition's - objects. The caller supplies the catalog. -- A catalog `archive` entry with a `store` and no `replay` path declares its - spans durable on that broadcast. The exporter still lists a capped window - for it; only the explicit `export::Config::history` mode lists the whole - retained timeline and lets only its pops trim it (`durable` in - `rs/moq-hls/src/export/mod.rs`). History mode FETCHes stored timeline - groups from group 0, and refuses a recording whose start retention pruned - (`Error::HistoryPruned`). - -`rs/moq-archive` stores the versioned objects on any `object_store::ObjectStore`. -`moq_archive::Writer` (`rs/moq-archive/src/writer.rs`) cuts each enrolled track -with its own `Segmenter`, streams frames as they arrive, and commits each track -independently: the record's object, the record, retention pops, then the -timeline's `segments/`. A failed object PUT drops that record. A DVR prunes -each timeline's oldest objects no checkpoint recovery reads. On a prefix -that already holds a recording, it replays each retained timeline through -`timeline::Producer::resume`, deletes objects past each track's committed tail, -resumes after the newest record's end (partway through a split group). Each -track's `.info` keeps the source route's epoch, and a resume under another -epoch fails to enroll (`Error::EpochMismatch`); the caller starts a new -prefix. A new group whose first timestamp precedes the recorded end fails the -recording instead of overlapping media time. A DVR deletes unreferenced objects and unneeded timeline -objects one grace period after recovery. -`moq_archive::Reader` (`rs/moq-archive/src/reader/mod.rs`) replays every timeline -onto a supplied `broadcast::Producer` and serves FETCH through `broadcast::Dynamic` -with a byte-bounded object LRU, stitching a group split across records and -growing a group whose records do not reach its end yet. `Reader::refresh` -follows by listing each timeline's keys after its cursor; `Reader::finish` -applies out-of-band finality. `rs/moq-archive/src/proof.rs` records one -multi-rendition broadcast end to end. - -### Decisions - -Moved from the deleted per-track timelines quest (2026-10-06 audit: its Rust -work landed in #4034 and its children are this line's own): - -- One timeline track per track, live and recorded. `moq-mux` publishes them for - every broadcast; an unsubscribed track costs nothing. -- The catalog's root `archive` entry maps each track to its timeline, including - the catalog track itself. `replay`, `store`, and `version` stay beside it. -- Each track cuts on its own by one rule: at the first group boundary past a - 4s minimum (zero only for sparse data such as the catalog), splitting a - long-lived group by frame at an internal multiple of its declared duration. - Manual cuts stay as an optimization, such as - a video keyframe cutting audio so derived segments need fewer objects. -- A stored object may hold a frame range of a group, not only whole groups. -- HLS and DASH segments are derived at the edge from group timestamps, not - from storage objects. Fetching extra objects is fine when they land in the - reader's cache for the next request. -- `moq-hls` never retries a failed timeline, because the origin already reconnects - transient source failures. A replacement publisher that restarts group - numbering is a publisher bug: a broadcast, track, or group name always means - the same content, and MoQ has no ETag-style invalidation - ([#4556](https://github.com/moq-dev/moq/pull/4556)). A failed timeline - fails loud instead: the reference ends every playlist with `EXT-X-ENDLIST`, - and another rendition's playlist ends at its last covered segment rather than - listing gaps. -- HLS `EXT-X-TARGETDURATION` is fixed for the run, not the observed maximum - #4280 shipped. This reverses that PR's decision 4 (09-28 merged-PR audit). - The target comes from each timeline's declared duration (09-29 planning). - -This README owns one small fix (triaged 2026-10-08): `moq-mux`'s -`timeline::Consumer` passes a record whose `sequence` differs from its window -index through unvalidated -([review](https://github.com/moq-dev/moq/pull/4280#pullrequestreview-5332725290)); -refuse it as malformed, as `timeline::Producer::resume` already does. The -other #4280 Codex finding is fixed: `Timelines::track` refuses a name that is -still recording. - -### Format - -The format is the draft's -[Recording section](/drafts/draft-lcurley-moq-hang.md#recording), version 2. -The application chooses the object prefix, selected tracks, retention, and credentials; `moq-archive` owns the -portable layout and codecs: - -```text -//.info -//segments/ -``` - -A recorded track's `segments/` holds record `n` of its timeline; its -timeline, named with a `.timeline.z` suffix, stores the window groups committed -with record `n` under its own `segments/`. `.info` is versioned JSON with -the immutable priority and timescale, absent for an untimed track, whose -frames store no timestamps and whose records carry no `pts` or `duration`. A segment object is a versioned binary -envelope: a frame start, a group/frame table with timestamps and payload -offsets, then the original payloads. One GET populates an LRU with the adjacent -groups, while audio-only or low-rendition playback never downloads unrelated -tracks. There is no `.head`, manifest, `.complete`, or index object. - -### Retention - -An unbounded archive only pushes records. A DVR pops each track's expired -records while committing that track's next record, before storing its timeline -groups, then waits the configured grace period before deleting expired objects. -Each track keeps its newest record, so a static catalog outlives the video -recorded with it. HLS is a derived view of the archive, never a second stored -copy. - -### Managed boundary - -Per-project sealing, retention prefixes, recording keying, election, billing, -and platform discovery are downstream (moq.pro) concerns. The archive entry is -portable while the source catalog exists; a managed recordings API keeps -completed recordings discoverable after source teardown. Protected broadcasts -are out of scope. Archive storage and replay use the transport's generic FETCH -surface; [JavaScript FETCH](/quest/m1/js-fetch.md) supplies the missing browser -IETF support before browser archive implementation. Transport codecs are -owned by that prerequisite, not duplicated in archive storage. +Decided by the maintainer on 2026-10-17, replacing the timeline-indexed +archive (per-track `.timeline.z` records named by the catalog's root +`archive` entry, found through its `replay` broadcast and `store` URL): + +- **Formats break in place.** None of this runs in production, so the old + layout and catalog section are deleted with no version bump or migration + 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. +- **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 + 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, + 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 + stored keys and another epoch is another prefix. An optional DVR mode prunes + old objects. Rejected: an object per whole-broadcast window (every read pulls + every rendition) and size-based cuts. The + [producer](/quest/m1/archive/producer.md) records the rest. +- **Hosts serve archives, relays do not.** `archive::Consumer` serves a stored + prefix through `broadcast::Dynamic` and `track::Dynamic`, the archived + catalog included as an ordinary track, and a host publishes it at a path of + its choosing. Rejected: a moq-relay storage fallback. Also rejected: serving + the archive as a covering-prefix route under the original path, which + moq-lite routing defeats three ways: an exact live route shadows any prefix + route at any cost, an Epoch-pinned request is UNROUTABLE unless the winning + route has exactly that Epoch, and a refusal never falls through to another + route. Stitching archived history into the original path is deferred. +- **Seek by time.** A `Time(pts)` range end + ([Subscribe ranges](/quest/m1/subscribe-ranges/README.md)) is resolved by + whoever holds the track, the cache or an archive consumer. DVR seeks with it. +- **`hls` is for HLS only, and opt-in.** A per-rendition catalog field names a + track whose (first group, pts) records mark each HLS segment, and a publisher + enabling it aligns segments across renditions + ([Catalog HLS field](/quest/m1/archive/catalog-hls.md)). The root `archive` + section is deleted everywhere. Rejected: always-on timelines. + +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 +the moq-archive rustdoc that specifies it), and serving a stored broadcast +from a host. Each child fixes the stale docs it touches. ## Required -- [Fixed HLS target duration](/quest/m1/archive/hls-target.md) - one `EXT-X-TARGETDURATION` for the run, from the reference timeline's declared duration; an overrun is listed with a warning -- [Replay catalog](/quest/m1/archive/replay-catalog.md) - `moq_archive::Reader` republishes the recorded catalog live with `store` set, so stock `moq export hls` serves the whole replay of `moq import archive` -- [Idle flush](/quest/m1/archive/flush.md) - idle tracks are recorded within a bounded wall-clock delay, and `flush()` forces a track's pending record out -- [History from the retained floor](/quest/m1/archive/history-floor.md) - history mode lists a retained (DVR) recording from its oldest retained segment instead of refusing it +- [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 +- [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 +- [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 ## Related -- [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - a track's definition never changes for its name, so any recorded catalog describes every group of the tracks it lists +- [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - a track's definition never changes for its name, so the archived catalog describes every group of the tracks it lists - [e2ee](/quest/m2/e2ee/README.md) - protected broadcasts are excluded initially +- [DVR rewind](/quest/m2/archive-dvr.md) - seeks back through the cache, then an archive, with time ranges diff --git a/quest/m1/archive/catalog-hls.md b/quest/m1/archive/catalog-hls.md new file mode 100644 index 00000000000..00059cc7fa6 --- /dev/null +++ b/quest/m1/archive/catalog-hls.md @@ -0,0 +1,72 @@ +# [L] Catalog HLS field + +## Goal + +The hang catalog has no root `archive` section: `timelines`, `timescale`, +`replay`, `store`, and `version` are gone from `rs/hang`, `js/hang`, +`drafts/draft-lcurley-moq-hang.md`, `moq-mux`, and `moq-hls`. What remains of +timelines is an opt-in, per-rendition `hls` field that exists only for HLS +compatibility: it names a track whose records give each HLS segment's first +group and pts, and declares the nominal segment duration. A rendition without +it is not HLS-renderable. + +## Plan + +Decided by the maintainer on 2026-10-17: + +- No publisher-owned archive pointer remains; archives are found by the host + that stores them ([epic](/quest/m1/archive/README.md)). +- **Renamed to `hls`** (catalog field, track naming, types, wording), since it + exists only for HLS compatibility, not archiving or seeking; seeking uses + `Time(pts)` ranges. The track naming convention becomes `.hls.z`; + consumers still use the advertised name. +- **Opt-in.** `moq-mux` publishes it only when the application enables it, + and nothing publishes it by default. Rejected: always-on timelines for every + track, which cost every publisher for a feature most never use. +- **Aligned segments are the opt-in contract.** A publisher that enables `hls` + aligns group boundaries across the renditions it lists, so segment n starts + at the same pts in every rendition. This is part of why it is opt-in. An edge + maps segment n to record n of each rendition directly; the reference + rendition resolving others against their own timelines, keyframe snapping, + and `EXT-X-GAP` for a rendition with no nearby boundary all go away + (`rs/moq-hls/src/export/spans.rs`). An edge that finds renditions misaligned + refuses that segment loudly rather than repairing it. +- **Per rendition, video and audio only.** The catalog track, data tracks, and + untimed tracks get no `hls` field. Each declares its nominal segment + duration, which is the landed per-timeline `duration` (#5715) moved onto the + rendition; it never changes for the life of the track. +- **Records are (first group, pts).** Segments start at group boundaries, so + mid-group splits, frame positions, and record `end`s go away. `EXTINF` + derives from the next record's pts; an ended track's last record carries an + explicit duration. Rejected: group only, which would make the edge read media + to time a segment. +- The archive does not use these records, and DVR seeks with `Time(pts)` + ranges. + +Guidance: + +- `moq-hls` history mode becomes explicit configuration only: today the + catalog's `store` marks a timeline durable (`durable` in + `rs/moq-hls/src/export/mod.rs`). +- Fold in the old README's fix: `moq-mux`'s consumer refuses a record whose + `sequence` differs from its window index, as its `Producer::resume` already + does + ([review](https://github.com/moq-dev/moq/pull/4280#pullrequestreview-5332725290)). +- `js/hang` updates its schema and consumer; the JS producer and the + `@moq/publish` opt-in are [JS HLS field](/quest/m2/js-hls.md). +- Update the draft's Timeline section (renamed for HLS) and changelog, + `doc/concept/hang.md`'s Archive bullet, and `doc/bin/hls.md` (how to opt in, + and the alignment it requires); run `just drafts check`. + +Test that a catalog with the old root `archive` entry still parses with the +entry ignored, that nothing publishes `hls` unless enabled, that aligned +renditions list matching segments with a declared target, that a misaligned +segment is refused, and that a rendition without `hls` gets no playlist. + +Public API: `hang::catalog::Archive` is deleted, the rendition types gain an +`hls` field in Rust and JS, and `moq-mux` gains the opt-in. Wire: the hang +catalog format. + +## Required + +- [Archive consumer](/quest/m1/archive/consumer.md) - the old writer and reader, which read the root `archive` section, are gone first diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md new file mode 100644 index 00000000000..07955cfa3a9 --- /dev/null +++ b/quest/m1/archive/consumer.md @@ -0,0 +1,66 @@ +# [M] Archive consumer + +## Goal + +`archive::Consumer` serves one broadcast's stored key prefix as an ordinary +broadcast: every stored track, the archived catalog included, with its +original groups, frames, timescale, and priority. A host publishes it at a path of its choosing; +moq-relay never uses it. `moq export archive` records through +`archive::Producer` and `moq import archive` serves through +`archive::Consumer`. It replaces `moq_archive::Reader`. + +## Plan + +Decided by the maintainer on 2026-10-17 (see the +[epic](/quest/m1/archive/README.md)): + +- One cached LIST of the prefix is the index: it names the tracks and maps + any group or pts to an object. Each track's `.info` supplies its timescale + and priority before any media is read. A growing archive refreshes each + track with its own cursor, a LIST of that track's key prefix StartAfter its + newest known key, since one prefix-wide cursor skips a new key under a + lexically earlier track. A delimiter LIST of the broadcast prefix finds + tracks added since. It also re-reads a track's newest object, which the + producer may rewrite in place while an untimed group is open. +- Each served track advertises no Publisher Max Age limit (TRACK_INFO's + encoded `0`), not the source's: the store's retention bounds what it holds, + and DVR pruning is the publisher dropping a group early, which TRACK_INFO + permits. Advertising the source's bound would expire every archived group + older than it relative to the newest. +- Serve through `broadcast::Dynamic` and `track::Dynamic`. Create every listed + track up front, so a SUBSCRIBE_ALL on the served broadcast sees 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. + +Guidance: + +- An absent group below the newest stored one is a gap and reports as dropped. + A group below the oldest stored key was pruned or never stored and reports + the same way; history-mode callers rely on the start answering with what + remains ([History from the retained floor](/quest/m1/archive/history-floor.md)). +- Keep a byte-bounded object LRU so neighbouring groups come from one GET. +- When [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) lands, the + `track::Dynamic` handler resolves a `Time(pts)` end by picking the object + from the key listing, then the group containing pts from that object's + group timestamps, whichever quest lands second. +- Move `rs/moq-hls/src/export/archive_tests.rs` onto a consumer broadcast with + its archived catalog, deleting the hand-built catalog. +- Update `doc/bin/cli.md` and `doc/bin/hls.md` inline with an export, import, + and HLS example. + +Test with the in-memory store: a round trip preserving structure and payloads +byte for byte, a gap, a pruned start, a growing archive followed through +refresh (including two tracks where a new key sorts before the other track's +cursor, and a track added mid-recording), history older than the source's max +age still served, an untimed track, and an empty or unrecognized prefix +refused loudly. + +Public API: `archive::Consumer` replaces `Reader`. Wire: none. + +## Required + +- [Archive producer](/quest/m1/archive/producer.md) - the layout this serves diff --git a/quest/m1/archive/flush.md b/quest/m1/archive/flush.md deleted file mode 100644 index 967e7dcbabe..00000000000 --- a/quest/m1/archive/flush.md +++ /dev/null @@ -1,43 +0,0 @@ -# [M] Idle tracks still get recorded - -## Goal - -A recording closes the record holding every frame within a bounded wall-clock -delay of its arrival, and hands it to the committer, even when its track goes -idle, and a caller can force one track's pending -record out with `flush()`. An append-only group that stops growing, or a track -whose publisher stops sending, no longer waits for the next frame or the end of -the recording. - -## Plan - -Decided: - -- Time decisions use elapsed = max(wall clock, pts) everywhere, so the existing - cutting rule does the work with no new setting: an open group idle past - internal safety ceiling splits between frames, and a finished sparse - group (minimum 0) stores at once. Peer timestamps alone can never stall a - cut, since the local wall clock bounds them. A forward pts leap can close a - record early; a new record then starts at the leaping frame, so each leap - costs at most one extra record. Say in the PR how repeated leaps are bounded. -- The segmenter stays pure: it takes `now` as an input and exposes the next - wall-clock deadline at which a record must close. Callers arm the timer. The - moq-archive writer uses tokio time; moq-mux's `Recorder` must stay wasm-safe - (moq-mux builds for wasm32), so it uses `web_async` time, never - `tokio::time` (moq-net's runtime `Timer` went away in #4437). -- Manual flush is per track: `Control::flush(name)` on the writer, beside the - broadcast-wide `cut`, and `Recorder::flush()` on a timeline. -- Rust only (decided 2026-10-07): JS publishes no timeline yet, so its idle - deadline and `flush()` belong to - [JS per-track timelines](/quest/m2/archive-js-timelines.md), and this quest - does not wait on the port. -- The bound covers closing a record, not upload latency: a slow object-store - PUT still delays the durable commit, which the writer already serializes. -- Tests inject `now` into the segmenter and run the writer on paused tokio - time, per the repository's mock-time rule. Cover an idle open group, an idle - track after a finished group, a manual flush mid-group, and a publisher whose - timestamps run ahead of or behind the wall clock. - -## Related - -- [JS per-track timelines](/quest/m2/archive-js-timelines.md) - carries the JS idle deadline and `flush()` diff --git a/quest/m1/archive/history-floor.md b/quest/m1/archive/history-floor.md index 0bc2f645029..dafdddb9f08 100644 --- a/quest/m1/archive/history-floor.md +++ b/quest/m1/archive/history-floor.md @@ -1,42 +1,42 @@ -# [M] History mode starts at a DVR recording's retained floor +# [S] History from the retained floor ## Goal -An HLS export in history mode lists a recording that `moq_archive::Writer` -retention has pruned, starting at its oldest retained segment, and the -Reader's timeline index stays within the retained window. Today -`moq_hls::export::Config::history` refuses it with `Error::HistoryPruned` -before listing anything, because the exporter cannot tell a pruned start from -missing history. +An HLS export in history mode lists an archive consumer broadcast whose +oldest objects were deleted, starting at its oldest stored segment. Today +`moq_hls::export::Config::history` refuses a pruned start with +`Error::HistoryPruned` before listing anything, because the exporter cannot +tell a pruned start from missing history. A gap after the floor still fails, +never listing as a complete recording. ## Plan -Decided 2026-10-11 (#5236): refuse retained recordings now, and quest the -floor. A gap after the floor must still fail, never list as a complete -recording. +Decided 2026-10-11 (#5236): refuse pruned recordings now, and quest the floor. -Prefer a subscribe range: once moq-lite-07 ranges land -([Subscribe ranges](/quest/m1/subscribe-ranges/README.md)), an `Absolute(0)..` -range answers from the oldest group that still exists and names the pruned -ones with SUBSCRIBE_DROP, so history starts at the first delivered group. -Otherwise the Reader answers a request below its retained floor with an error -distinct from a missing group. +Decided 2026-10-17: kept as its own `moq-hls` quest rather than folded into +the [Archive consumer](/quest/m1/archive/consumer.md). The consumer only +answers a group below its oldest stored key as dropped; this quest makes +history mode start there. The old Reader's timeline-map pruning and any +retention field in the catalog are gone with the reader and the root +`archive` section. The archive producer's DVR mode is what prunes the start. -The same floor lets `moq_archive::Reader` prune its timeline-group map -(`Shared::timelines`), which today grows with a long retained recording's -whole lifetime: its refresh lists only objects after its cursor, so it never -sees the writer's deletions. Drop mappings below the floor rather than -bounding the map by age or size, which could discard retained history -(#5236 review). +History mode reads the archived `hls` track's groups from group 0. Start +at the first `hls` group the broadcast delivers, using its checkpoint's +retained window, and skip records whose first media group is below the floor. +Once [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) lands, an +`Absolute(0)..` range answers from the oldest group that exists and names the +rest with SUBSCRIBE_DROP; before that, the consumer's drop serves the same +purpose. -Surface retention in the catalog's archive section only if the exporter still -needs to know up front; that changes the hang format (`js/hang`, -`drafts/draft-lcurley-moq-hang.md`). +Test an archive whose oldest objects are deleted: history lists from the first +stored segment, and a hole after it still fails. -Test a retained recording whose first timeline objects are deleted: history -lists from the first retained segment, and a hole after it still fails. +## Required + +- [Archive consumer](/quest/m1/archive/consumer.md) - serves the pruned archive and drops groups below its floor +- [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the `hls` records history mode reads ## Related -- [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - a range from group 0 answers from the oldest group that still exists +- [HLS failure signal](/quest/m1/hls-failure-signal.md) - the other history-mode gap left by #5236 - [DVR rewind](/quest/m2/archive-dvr.md) - seeks through the same bounded archive diff --git a/quest/m1/archive/hls-target.md b/quest/m1/archive/hls-target.md index 482a5d58465..d0328577752 100644 --- a/quest/m1/archive/hls-target.md +++ b/quest/m1/archive/hls-target.md @@ -3,9 +3,9 @@ ## Goal A `moq-hls` media playlist advertises one `EXT-X-TARGETDURATION` for the -whole run, taken from the reference timeline's declared duration, so every -edge and every reload agree on it. Segments stay one-to-one with timeline -records: the edge never skips, splits, or renumbers one. +whole run, taken from the declared duration of the rendition's opt-in `hls` +field, so every edge and every reload agree on it. Segments stay one-to-one +with `hls` records: the edge never skips, splits, or renumbers one. ## Plan @@ -17,22 +17,22 @@ and falls when that record is evicted. Codex flagged it The 09-28 audit reversed #4280's decision 4 (the observed maximum), and the 09-29 planning settled the rest: -- The target is `ceil` of the reference timeline's declared duration. - Until that timeline's entry is declared, the rendition has no playlist yet. +- The target is `ceil` of the declared `hls` duration from + [Catalog HLS field](/quest/m1/archive/catalog-hls.md) (retargeted + 2026-10-17 from the root `archive` map). Until it is declared, the rendition + has no playlist yet. - A segment whose rounded `EXTINF` exceeds the target (a GOP that overran the publisher's declared value) is listed anyway, with a rate-limited warning. This deliberately deviates from RFC 8216 4.3.3.1's MUST, because refusing the segment loses content, and splitting or skipping it at the edge breaks the stable media sequence numbers of RFC 8216 6.2.2. Document the deviation. hls.js tolerates it; Apple's mediastreamvalidator flags it. -- A record the publisher split mid-group at its safety ceiling is listed as - its own segment. Stop merging split halves back into one range - (`a_frame_split_record_is_not_a_video_boundary` in `spans.rs`). This is legal - because moq-hls never emits `EXT-X-INDEPENDENT-SEGMENTS`, so a segment may - start without a keyframe. -- A non-reference rendition keeps the reference record's `EXTINF`, even though - its keyframe-snapped content can run longer. The user decided this needs no - change. + +Decided 2026-10-17: renditions that opt into `hls` align their segments, so +the old non-reference `EXTINF` rule (a keyframe-snapped rendition keeping the +reference record's duration) and the split-record handling +(`a_frame_split_record_is_not_a_video_boundary` in `spans.rs`) go away with +Catalog HLS field rather than here. Replace `target_duration_follows_the_observed_segments` with tests that pin the declared target across a window whose segment durations vary, and that @@ -41,3 +41,7 @@ comment ("default 1s minimum"; the minimum is 2s). Hold the target per playlist URI, not per `Rendition`: `renditions::Producer::sync` replaces the `Rendition` on a catalog reconfigure, so test that a reconfigure mid-run keeps the original target. + +## Required + +- [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the opt-in `hls` field whose declared duration sets the target diff --git a/quest/m1/archive/producer.md b/quest/m1/archive/producer.md new file mode 100644 index 00000000000..38bf695961b --- /dev/null +++ b/quest/m1/archive/producer.md @@ -0,0 +1,101 @@ +# [XL] Archive producer + +## Goal + +`archive::Producer` takes one SUBSCRIBE_ALL prefix subscription and stores +every track of every matching broadcast 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 +its timelines and recovery machinery. + +## Plan + +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. +- **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, + which is how a gap is recorded. Anything younger is still in the live cache. +- **Objects.** Groups coalesce in order into per-track objects, cut at a group + boundary once a configured duration passes. An oversized timed group splits + between frames. An untimed track (no timescale) cuts by wall clock at group + 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 + 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 + group and time lookups; there is no index object or manifest. Rejected for + untimed: `-`, since an untimed group is never + split. +- **Untimed groups never split.** While an untimed group is open, the bounded + flush rewrites its object in place under the same key, so keys stay unique + without a frame suffix; the consumer re-reads the newest object of a growing + track. Folded from the untimed quest (#5720 landed untimed in the old writer). +- **Track properties.** A small `//.info` holds the source + properties the stored media depends on (timescale, priority), written before + the track's first object, so a consumer knows them before reading media. + Properties never change for a track name, so a resume that finds different + ones fails loud. The source's max age is not stored: it bounds the live + cache, not the archive, and replaying it would expire archived history + (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. +- **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 + upload latency. +- **No manual `flush()` or `cut()`** until a caller needs one. +- **Resume.** Under a key prefix that already holds objects, each track + 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 + 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 + 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: + it deletes a track's objects older than a configured window after a grace + period, so readers in flight finish, and always keeps each track's newest + object so a static catalog outlives the media around it. Rejected: no + retention in the crate. +- **Layout docs.** Delete the hang draft's Recording section + (`drafts/draft-lcurley-moq-hang.md`): the layout is a `moq-archive` format, + not a hang one. Document it in moq-archive rustdoc and the epic's Archives + doc page. + +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 +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. + +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 + +## Related + +- [Archive consumer](/quest/m1/archive/consumer.md) - reads this layout back diff --git a/quest/m1/archive/replay-catalog.md b/quest/m1/archive/replay-catalog.md deleted file mode 100644 index 583f328bda9..00000000000 --- a/quest/m1/archive/replay-catalog.md +++ /dev/null @@ -1,85 +0,0 @@ -# [S] Replay catalog - -## Goal - -A replayed recording plays as VOD HLS from the stock server: `moq export -archive`, then `moq import archive`, then `moq export hls` lists the whole -recording with no embedder supplying a catalog. The replay broadcast publishes -its recorded catalog live, stamped with the recording's `store` and `version`, -so an exporter in history mode treats its timeline as durable and lists past -`--window`. A `--follow` replay grows like an event playlist. - -[Track Identity](/drafts/draft-lcurley-moq-hang.md#identity) guarantees a track's -identity never changes for its name, and resolution changes in band below -its ceilings, so one catalog describes every group of the tracks it lists; -nothing here picks a catalog per group. - -Decided 2026-10-08 (review): the catalog at the live edge is the union of -every rendition recorded, so a rendition removed mid-recording stays -discoverable; one no longer live is listed with `enabled: false`. VOD -consumers (HLS archive mode, DVR) list a rendition that has recorded media -regardless of `enabled`; live watch keeps filtering disabled ones. HLS -segments every playlist from one reference rendition (`reference()` in -`rs/moq-hls/src/export/mod.rs`), so when the reference's recording ends -while another continues, archive mode advances the reference to the -continuing one without dropping earlier rows, or the union stalls the -playlist at the old rendition's end. Paced -replay (m3) replays the catalog history instead. Test a rendition removed -before the recording ends, including the reference while another -continues: both the first segment and the tail stay listed. - -## Plan - -Today the reader writes live groups only for timelines; the recorded catalog -is FETCH-only, so a SUBSCRIBE to it on the replay broadcast never sees a group -and `moq-hls` never finds the `archive` entry. The HLS archive tests work around -this by hand-building a catalog. - -- Republish the recorded catalog live as that union, updated in timeline - order so a `--follow` replay picks up renditions recorded after it opened. -- Stamp `store` with the URL passed to `import archive`, and `version` with the - recording format. Refuse a URL carrying userinfo and strip its query so - credentials never land in a catalog, and let the importer opt out of - advertising the URL at all. `replay` stays unset: the timelines live on this broadcast. -- The logic lives in `moq-archive` (`moq_archive::Reader`), so any host of - the archive behind the root claim republishes and stamps the catalog, not - only `moq-cli` (decided 09-29 in [DVR rewind](/quest/m2/archive-dvr.md)). - `moq-cli` only passes the URL and the opt-out. Select the catalog track by - catalog format as `export archive` does: hang and hang.z are stamped, MSF - is refused. -- The replay keeps minting a fresh epoch per run (#4942), so a - live-to-archive handover at one path is a `Restart`, not a resume (decided - in the 2026-10-08 audit, after #5043's review). Reusing the recorded - `.info` epoch was rejected for now: the archive writes its own - `.timeline.z` groups under the live names, and this quest rewrites - catalog groups, so the same name, epoch, and group number could carry - different bytes. Revisit only once replayed metadata is byte- and - sequence-identical to live. So `moq import archive` refuses `--epoch`, and any - `moq-archive` host mints its own epoch or takes one from its caller, never - defaulting to `.info`'s. -- The recorded catalog's `archive` entry describes the source's live - timelines, not the recording's, so replace it with the timelines the reader - replays rather than trusting the recorded ones. - -A live playlist stays capped even for a durable timeline; listing past the -window is the explicit `moq_hls::export::Config::history` mode, which `moq -export hls` does not expose yet. Give it a flag here. History mode lists from -stored timeline groups, including records older than the live restatement -(#5236). Reuse that recovery in the end-to-end proof rather than implement it -again. [Retained-floor history](/quest/m1/archive/history-floor.md) and -[HLS failure signaling](/quest/m1/hls-failure-signal.md) remain separate work. - -Add a CI test that records a broadcast longer than the default window, replays -it, and asserts the stock exporter lists segment 0 with a durable -`timeShiftBufferDepth`. Consider moving the HLS archive tests onto the -republished catalog instead of their hand-built one. - -Update `doc/bin/cli.md` and `doc/bin/hls.md` inline with an export, import, and -serve example. - -Cover a DVR recording whose catalog outlived its first video segment, and fail -loudly on a recording with no catalog. - -## Required - -- [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - Rust publishers keep the guarantee that one recorded catalog describes every group diff --git a/quest/m1/archive/subscribe-all.md b/quest/m1/archive/subscribe-all.md new file mode 100644 index 00000000000..de814093b9e --- /dev/null +++ b/quest/m1/archive/subscribe-all.md @@ -0,0 +1,75 @@ +# [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 new file mode 100644 index 00000000000..56e8fade427 --- /dev/null +++ b/quest/m1/archive/subscribe-tracks.md @@ -0,0 +1,41 @@ +# [L] SUBSCRIBE_TRACKS and PUBLISH + +## 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. + +## 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. + +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. + +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. + +Public API: none beyond SUBSCRIBE_ALL's hook. Wire: moq-transport support for +two existing messages. + +## Required + +- [SUBSCRIBE_ALL](/quest/m1/archive/subscribe-all.md) - the model hook this maps onto diff --git a/quest/m1/catalog-identity-rs.md b/quest/m1/catalog-identity-rs.md index 5f5eac72145..fb42912f143 100644 --- a/quest/m1/catalog-identity-rs.md +++ b/quest/m1/catalog-identity-rs.md @@ -68,4 +68,4 @@ the contract. ## Related -- [Archive](/quest/m1/archive/README.md) - storage and replay consume the identity contract +- [Broadcast archives](/quest/m1/archive/README.md) - an archived catalog describes every stored group by the identity contract diff --git a/quest/m1/js-fetch.md b/quest/m1/js-fetch.md index bfa83ff5ab1..0e1a8105a7d 100644 --- a/quest/m1/js-fetch.md +++ b/quest/m1/js-fetch.md @@ -47,5 +47,4 @@ claims that change. ## Related -- [Browser archive](/quest/m3/archive-browser.md) - supplies memory or OPFS archive data through the same request surface - [IETF fill timeout](/quest/m1/ietf-fill-timeout.md) - native timeout budgets and gap dispositions build on this responder diff --git a/quest/m1/lite07-finalize.md b/quest/m1/lite07-finalize.md index 809bce3693a..cafd9f22258 100644 --- a/quest/m1/lite07-finalize.md +++ b/quest/m1/lite07-finalize.md @@ -62,6 +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. +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, so no codec work waits on the cut. @@ -84,6 +88,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 ## Related diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index 255d1ff1f69..20f0f240239 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -3,7 +3,8 @@ ## Goal A moq-lite-07 SUBSCRIBE replaces FETCH. One subscription asks for any number -of group ranges, past or live, in ascending or descending order, and the +of group ranges, past or live, bounded by group or by time, in ascending or +descending order, and the publisher serves their union from one cursor without sending a group twice. Every sequence in range either arrives or is named by a SUBSCRIBE_DROP, so nothing blocks on a cache miss. Relays fill misses upstream with ranges, not @@ -21,7 +22,7 @@ relay bounds a sparse FETCH only if the model can request ranges: #5385; over moq-transport the upstream order is fixed at SUBSCRIBE, since no draft lets an update change it. Subscriber Max Age (the model's `max_delay`) stays as a cap alongside the - ranges, since a group's timestamp isn't always known. Decided 2026-10-10: + ranges, since an untimed track's groups have no timestamp to bound. Decided 2026-10-10: historical requests also obey that cap and publisher max-age. An omitted FETCH budget means publisher max-age alone; translation to a range must not accidentally give it the live SUBSCRIBE zero-delay default. SUBSCRIBE_UPDATE @@ -61,6 +62,18 @@ relay bounds a sparse FETCH only if the model can request ranges: still caps every range, so `Absolute(0)..` means everything not stale. A relay that cannot resolve a relative end locally forwards it upstream as relative. +- **Time ends** (maintainer, 2026-10-17, for DVR seek in the + [archive line](/quest/m1/archive/README.md)). A range end may also be + `Time(pts)`, in the track's timescale. Whoever holds the track resolves it: + the cache from its groups' timestamps, or a `track::Dynamic` handler such as + an archive consumer. A start resolves to the group containing pts, and an + end to just after that group. A lite-07 relay that cannot resolve one + forwards it upstream unchanged. An untimed track refuses a time end, and a + moq-transport bridge resolves it locally or refuses it, since moq-transport + has no time filter. It is part of lite-07 and must land before + [lite-07 finalizes](/quest/m1/lite07-finalize.md), which this line gates. This amends the 09-29 + premise that ranges are group-only because a group's timestamp isn't always + known: timed tracks know it, and untimed ones refuse. - Ranges are frame-precise (`Position`), not whole groups. The IETF joining FETCH for a mid-group SUBSCRIBE's uncached prefix stays, because today's bridge relies on it (maintainer, 09-29). @@ -73,7 +86,7 @@ every sequence delivered or dropped. ## Required -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `Dynamic` fills misses by range, and a subscriber reads only its own ranges +- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `Dynamic` fills misses by range, a subscriber reads only its own ranges, and a range end may be a time - [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - publishers name every group they won't deliver - [Lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) - the lite-07 wire, Rust publisher and subscriber, and the draft - [moq-transport upstream updates](/quest/m1/subscribe-ranges/ietf-update.md) - the upstream request follows the aggregate priority; its order stays fixed, as every draft requires diff --git a/quest/m1/subscribe-ranges/dynamic.md b/quest/m1/subscribe-ranges/dynamic.md index 90d0f2aba08..ad5fd0d5672 100644 --- a/quest/m1/subscribe-ranges/dynamic.md +++ b/quest/m1/subscribe-ranges/dynamic.md @@ -5,7 +5,8 @@ A publisher's `track::Dynamic` receives range requests for misses instead of one request per group, and a track serves a subscription's ranges from one cursor, in its order, without duplicating a group, naming every sequence in -range it won't deliver. `requested_group` and `fetch_group` are replaced, not +range it won't deliver. A range end may be a `Time(pts)`, resolved by +whoever holds the track. `requested_group` and `fetch_group` are replaced, not kept beside it. ## Plan @@ -16,8 +17,17 @@ still reads every later group the aggregate brings in. Serving a range locally means filtering a subscriber's reads to its own ranges, and a miss below the cache becomes a range request on `Dynamic` rather than a group request. +- Add a `Time(pts)` anchor beside `Absolute` and `Relative` (see the + [line](/quest/m1/subscribe-ranges/README.md)), carried through moq-ffi and + every binding like the other anchors. A track resolves it from its cached + groups' timestamps and passes an unresolved one to its `track::Dynamic` + handler; an untimed track refuses it. Test a time start and end resolved + against the cache, an unresolved one passed through to a handler, and an + untimed track refusing one. - Replace `requested_group` and `fetch_group` rather than keep both. Update - every caller (relay, moq-archive, hls, the ladder) in the same PR, plus the + every caller (relay, moq-archive, hls, the ladder) in the same PR, + including the archive consumer's time resolution from its key listing if it + has landed, plus the published surfaces built on them, per the cross-package table: moq-ffi (`fetch_group`, `requested_group`), moq-c (`poll_requested_group`), every binding wrapper and its `doc/lib` page, and `moq fetch` through diff --git a/quest/m1/subscribe-ranges/ietf.md b/quest/m1/subscribe-ranges/ietf.md index 1ec91ea49d6..ef161efd4b1 100644 --- a/quest/m1/subscribe-ranges/ietf.md +++ b/quest/m1/subscribe-ranges/ietf.md @@ -33,6 +33,10 @@ bounded FETCH that ends exactly at the track's last object reports when the track has finished and the FETCH reaches its final object, never from a live track's Largest Object, and test both cases. +moq-transport has no time filter, so a downstream `Time(pts)` end is +resolved against the relay's cache before it goes upstream as a group range, +or refused when the cache cannot resolve it. + ## Required - [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - the range requests this answers diff --git a/quest/m1/subscribe-ranges/js.md b/quest/m1/subscribe-ranges/js.md index 97852e818a3..831ab0ee0c1 100644 --- a/quest/m1/subscribe-ranges/js.md +++ b/quest/m1/subscribe-ranges/js.md @@ -9,7 +9,7 @@ publisher serves requested ranges that are no longer in its live cache. ## Plan -Mirror the Rust names. +Mirror the Rust names, including `Time(pts)` ends and their refusal on an untimed track. Decided in the 2026-10-05 audit: the producer half of [JavaScript FETCH](/quest/m1/js-fetch.md) folds in here, so `@moq/net`'s diff --git a/quest/m1/subscribe-ranges/lite.md b/quest/m1/subscribe-ranges/lite.md index 69ff71392a9..a6a073eee9b 100644 --- a/quest/m1/subscribe-ranges/lite.md +++ b/quest/m1/subscribe-ranges/lite.md @@ -9,8 +9,10 @@ lite upstream by subscribing to the missing ranges. ## Plan -Each range end encodes `Absolute(group, frame)`, `Relative(n)`, or open, and -a SUBSCRIBE with no ranges is refused. Older wires map at the codec only, with +Each range end encodes `Absolute(group, frame)`, `Relative(n)`, `Time(pts)`, +or open, and a SUBSCRIBE with no ranges is refused. A `Time` end has no +older-wire mapping: the answering hop resolves it before going upstream on +lite-06 or earlier, or refuses it. Older wires map at the codec only, with no change to a published version: lite-06 `Group Start` 0 with `Frame Start` 0, and pre-06 absent, are `Absolute(0)..` capped by max age (the oldest group not yet stale); any other lite-06 pair is an absolute start. When merged diff --git a/quest/m2/README.md b/quest/m2/README.md index 7425fc7d62e..39219c30c5b 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -124,7 +124,6 @@ ranked just below deterministic TS legs, which it requires. - [Browser media report](/quest/m2/rs2ts-browser-report.md) - compare Rust-backed and handwritten @moq/net through full media workloads, supplementing the pre-adoption comparisons - [mTLS on tls://](/quest/m2/tls-listener-mtls.md) - a `tls://` listener can identify a cluster peer by its client certificate - [Link quality](/quest/m2/link-quality.md) - a radio link's cost follows its measured quality without flapping routes -- [Archive recovery listing](/quest/m2/archive-recovery-listing.md) - a resumed DVR lists what changed since its checkpoint, not every stored group - [Relay io_uring packages](/quest/m2/relay-io-uring-package.md) - Linux relay packages ship io_uring once the ring is on par with tokio - [Opus implementation](/quest/m2/audio-opus-backend.md) - compare Opus codec quality, CPU, build cost, and the loss recovery each backend offers - [Latency ledger](/quest/m2/latency-ledger.md) - a viewer reports its share of playback delay stage by stage: jitter buffer, decode, render, and device @@ -159,7 +158,7 @@ ranked just below deterministic TS legs, which it requires. - [DVR rewind](/quest/m2/archive-dvr.md) - follow-on capability after the release contract is stable -- [JS per-track timelines](/quest/m2/archive-js-timelines.md) - follow-on capability after the release contract is stable +- [JS HLS field](/quest/m2/js-hls.md) - follow-on capability after the release contract is stable - [Demo dashboard shows viewer lag](/quest/m2/qos-lag-dashboard.md) - follow-on capability after the release contract is stable diff --git a/quest/m2/archive-dvr.md b/quest/m2/archive-dvr.md index 1bf89e5d70e..1096026bb7f 100644 --- a/quest/m2/archive-dvr.md +++ b/quest/m2/archive-dvr.md @@ -2,60 +2,36 @@ ## Goal -A viewer seeks through a bounded archive and returns to live playback using the -same timeline and group-range objects as an unbounded archive. +A viewer seeks back by time and returns to live playback. A seek is a +`Time(pts)` range, answered by the live broadcast's cache while the content is +young enough, and otherwise by an archive broadcast that a host serves through +`archive::Consumer` at a path the application gives the player. ## Plan -The recording writer owns retention, deletion grace, checkpoint recovery, and -restart cleanup. This quest consumes that contract and owns viewer seek and -return-to-live behavior, not a second writer implementation. - -The player reads the archive timeline, FETCHes old groups through the normal -miss chain, and splices back to SUBSCRIBE at the live edge without opening a -second media format. Missing groups remain ordinary gaps. - -An unbounded archive can continue the same segment numbering without rewriting -objects retained from an earlier DVR window. - -Test seeks within the retained window, expiry during a seek, missing groups, -restart recovery, and return to live without duplicated or rewound playback. -Use the writer/reader fixtures; a retention defect is fixed in its owning layer. - -The reader evicts popped spans from its object cache, but a group it already -served stays in `moq_net`'s track cache until the pool reclaims it. Decide -whether expiry during a seek needs a group eviction API in `moq-net`. - -`moq-hls` reads a timeline from the catalog's own broadcast, and an -`archive.replay` path only marks it non-durable. Decided (09-29): the exporter -does not follow `replay`, and viewers don't address a separate replay -broadcast. A recording is the broadcast. Since Wildcard (#4403) the archive -serves the source path through the root claim, and a live announcement shadows it. So when live -ends, `moq-hls` resolves the same name and falls through to the recording: -playlists keep serving for rewind and for players finishing the last -segments. The fall-through needs the recording to publish its catalog live, -since `moq-hls` subscribes to it rather than FETCHing it. Decided (09-29): -that republishing moves into `moq-archive`, so any host of the archive -behind the root claim does it, not only `moq-cli`, as [Replay -catalog](/quest/m1/archive/replay-catalog.md) now plans. - -During live, rewind needs no handover: a recorded broadcast's live timeline -is durable, and every group it lists is promised available, so a seek past -the live window FETCHes old groups through the normal miss chain down to the -archive. A live HLS playlist stays capped to its window even for a durable -timeline (decided 10-05), so an HLS seek past it needs the explicit -`moq_hls::export::Config::history` listing. `moq-hls` gains -no linger (the `hls-linger` quest was dropped, because an unannounced -broadcast can't be FETCHed and a linger would only serve the cache). Test -the handover: a live HLS session keeps its playlist URIs and media sequence -numbers when the name moves from the live publisher to the archive, which -announces a fresh epoch ([Replay -catalog](/quest/m1/archive/replay-catalog.md)), so viewers follow a -`Restart` rather than resume. +Retargeted 2026-10-17 with the [archive line](/quest/m1/archive/README.md): +seeking uses time ranges, not timelines, and archives are found by the host, +not through a catalog pointer. The 09-29 decisions that a recording is the +broadcast at the source path, reached by falling through a live +announcement, are superseded: moq-lite routing never falls through to a prefix +route, so the archive lives at its own path. + +- Within the publisher's max age, the live broadcast's cache answers the seek. + Past it, the player subscribes to the archive broadcast with the same range + and splices back to the live broadcast at its live edge, without duplicated + or rewound playback. Missing groups remain ordinary gaps. +- How the player learns the archive path is the application's choice; the + player API takes it as input. +- Decided 2026-10-17: the player surface is `@moq/watch`. Rejected: a Rust + player first, which no consumer needs yet. + +Test seeks within the cache, past it into the archive, across a pruned start, +over missing groups, and return to live. ## Required -- [Replay catalog](/quest/m1/archive/replay-catalog.md) - the recording publishes its catalog live, so `moq-hls` finds it after the handover +- [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - the `Time(pts)` range a seek sends +- [Archive consumer](/quest/m1/archive/consumer.md) - serves a stored broadcast and resolves time ranges from its keys ## Closes diff --git a/quest/m2/archive-js-timelines.md b/quest/m2/archive-js-timelines.md deleted file mode 100644 index be497cf6210..00000000000 --- a/quest/m2/archive-js-timelines.md +++ /dev/null @@ -1,24 +0,0 @@ -# [M] JS per-track timelines - -## Goal - -`@moq/hang` publishes one timeline per track with the same records, cuts, and -catalog `archive` map as Rust. - -## Plan - -The read side landed with the archive line (#4034): the catalog schema parses -the `timelines` map and `Timeline.Consumer` reads one track's records. The -aligned multi-track producer was deleted rather than kept beside it, so JS -publishes no timeline today. - -Port the Rust segmenter, producer, and enrollment -(`rs/moq-mux/src/timeline.rs`) to `js/hang/src/timeline.ts`, and have the -legacy container report its groups again. Cover the same cut rules and a -static catalog outliving other tracks' records, and check the records against -Rust output in the interop suite. - -The JS `Timeline.Recorder` also gets the idle deadline and `flush()` that -[Idle tracks](/quest/m1/archive/flush.md) decides for Rust, so an idle browser -track's record closes within the same bound (Codex on #4301; moved here -2026-10-07 so the Rust flush does not wait on this port). diff --git a/quest/m2/archive-recovery-listing.md b/quest/m2/archive-recovery-listing.md deleted file mode 100644 index 2797aac43cf..00000000000 --- a/quest/m2/archive-recovery-listing.md +++ /dev/null @@ -1,19 +0,0 @@ -# [M] Archive recovery listing - -## Goal - -A DVR writer resuming a recording lists storage in proportion to what changed -since its last checkpoint, not every stored `segments/` object, while still never -deleting a referenced object. - -## Plan - -- Recovery today (`recover` in `rs/moq-archive/src/recover.rs`) lists the - whole recording, every track's `segments/`, before accepting input. Compare bounding it with an ordered - `list_with_offset` from the oldest retained range, with orphans below it left - to a background sweep, against a checkpointed listing marker. -- Benchmark recovery time and requests over archive size before and after. - -## Related - -- [Archive](/quest/m1/archive/README.md) - the line whose recovery this bounds diff --git a/quest/m2/js-hls.md b/quest/m2/js-hls.md new file mode 100644 index 00000000000..405d4db516e --- /dev/null +++ b/quest/m2/js-hls.md @@ -0,0 +1,28 @@ +# [M] JS HLS field + +## Goal + +`@moq/publish` publishes the opt-in per-rendition `hls` field and tracks Rust +does, with the same records and declared duration, when the application +enables it, so a browser-published broadcast exports as HLS like a native one. +Nothing publishes it by default. + +## Plan + +Retargeted 2026-10-17: the field exists only for HLS compatibility and is +opt-in ([Catalog HLS field](/quest/m1/archive/catalog-hls.md)). The archive +does not use it, so the old idle `flush()` and catalog and data track timelines +are dropped from this port. + +Enabling it carries the aligned-segment contract: the publisher starts groups +at the same pts across the renditions it lists, so segment n starts at the same +time in each. Check the encoders can honor it and refuse to enable it where +they cannot. + +The read side follows Catalog HLS field. Port the Rust producer +(`rs/moq-mux`) to `js/hang`, wire the opt-in through `@moq/publish`, and check +its records against Rust output in the interop suite. + +## Required + +- [Catalog HLS field](/quest/m1/archive/catalog-hls.md) - the field shape this ports diff --git a/quest/m3/README.md b/quest/m3/README.md index 29459cd8b52..a1866375a44 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -64,8 +64,7 @@ m4's upstream waits in here and moved m2 work with no consumer. - [MSFTS convergence](/quest/m3/msfts-convergence.md) - the demultiplexed TS lane converges on MSFTS where they differ, once an MSFTS implementer engages - [#2279](/quest/m3/2279-hang-typed-scte-35-ad-cue-signaling-carried-opaquely.md) - hang: SCTE-35 cues arrive immediately on an independent metadata track, optionally associated with a rendition - [CEA-608/708](/quest/m3/captions-cea.md) - captions carried inside video SEI become a real text rendition at import -- [Browser archive](/quest/m3/archive-browser.md) - the same contract for browser-published broadcasts -- [Paced replay](/quest/m3/archive-paced-replay.md) - a replay pushes its groups to live subscribers on one shared clock, so any live player plays it +- [Paced replay](/quest/m3/archive-paced-replay.md) - an archive consumer pushes stored groups to live subscribers on one shared clock, so any live player plays it - [Encoder feedback](/quest/m3/stats-encoder-feedback.md) - a Rust encoder reads its viewers' feedback and adapts its bitrate - [Interop runner withdraws before disconnecting](/quest/m3/interop-runner-approval.md) - condition: the maintainer approves posting and a released moq-tokio and @moq/net carry close() withdrawals diff --git a/quest/m3/archive-browser.md b/quest/m3/archive-browser.md deleted file mode 100644 index b307fbcb3a8..00000000000 --- a/quest/m3/archive-browser.md +++ /dev/null @@ -1,41 +0,0 @@ -# [L] Browser archive - -## Goal - -Browser-published broadcasts implement the same archive timeline, segment -layout, and FETCH behavior as native `moq-archive` users. - -## Plan - -Port the archive contract to the JS packages with memory and OPFS storage. The -application explicitly enrolls video, audio, catalog, or arbitrary data tracks; -the archive does not infer them from Hang. Record against the per-track -timelines from [JS per-track timelines](/quest/m2/archive-js-timelines.md). - -Commit each track independently: persist each stored span, including a frame -range of a still-open group, then publish that track's timeline record. Match -the object keys, envelope bytes, `.info` property values, and timeline -discovery the Rust writer uses, per the -[Recording section](/drafts/draft-lcurley-moq-hang.md#recording), without -inferring catalog-to-group applicability. -[Track Identity](/drafts/draft-lcurley-moq-hang.md#identity) addresses that separately. - -Use [JS ranges](/quest/m1/subscribe-ranges/js.md)'s on-demand range requests to -answer cache misses from memory or OPFS after relay eviction. This quest owns -storage lookup, the bounded segment LRU, and timeline-before-delete DVR -retention; the generic request lifecycle and IETF wire support land in the -prerequisite. Verify browser archive replay against native subscribers across -the supported drafts without duplicating transport dispatch or codecs here. - -Ship the contract in the `@moq/*` packages. A dashboard browser-to-HLS proof -remains downstream (moq.pro) work. - -Deferred in the 2026-09-30 audit and moved to m3 in the 2026-10-05 audit: no named consumer; the native archive -lands first. - -## Required - -- [JS ranges](/quest/m1/subscribe-ranges/js.md) - generic on-demand range serving in `@moq/net` -- [JavaScript FETCH](/quest/m1/js-fetch.md) - IETF FETCH dispatch onto it -- [JS per-track timelines](/quest/m2/archive-js-timelines.md) - the timeline this archive records against -- [Archive](/quest/m1/archive/README.md) - the native writer, reader, and DVR contract this ports diff --git a/quest/m3/archive-paced-replay.md b/quest/m3/archive-paced-replay.md index 69a87aea287..b5a7f74bb50 100644 --- a/quest/m3/archive-paced-replay.md +++ b/quest/m3/archive-paced-replay.md @@ -2,41 +2,41 @@ ## Goal -A live player plays a replayed recording unchanged: `moq import archive` -publishes each recorded track to SUBSCRIBE as well as FETCH, pushing its groups -when a shared clock reaches their media time, like `ffmpeg -re`. `export ts` -and web watch play a replay the way they play a live broadcast, and a viewer -who joins late joins mid-replay. +A live player plays a stored broadcast unchanged: `archive::Consumer` can push +each track's groups when a shared clock reaches their media time, like +`ffmpeg -re`, instead of answering only requests. `export ts` and web watch +play a paced `moq import archive` the way they play a live broadcast, and a +viewer who joins late joins mid-replay. ## Plan -- Today `moq_archive::Reader` publishes only the timeline track live and serves - media groups on FETCH through `broadcast::Dynamic`, so a subscriber sees none. - [Replay catalog](/quest/m1/archive/replay-catalog.md) adds the recorded - catalog live too. +Retargeted 2026-10-17 onto [Archive consumer](/quest/m1/archive/consumer.md), +whose broadcast already includes the archived catalog as an ordinary track. + - Decided 2026-10-08: the catalog paces on the same clock as the media, each - recorded catalog group emitted at its recorded time, so a viewer sees the - state that applied then. Left at its final live edge, a recording that ends - muted (`enabled: false`) or removes a rendition hides the earlier video, - since watch filters disabled renditions before subscribing. Test both. - At equal recorded timestamps the catalog goes first, so a media group never - arrives before the catalog update that lists it. -- One clock per import, not per subscriber. It starts at the earliest recorded - timestamp across the selected tracks and every track paces against it, so - tracks stay in sync and every viewer sees the same moment. -- A timeline gap is skipped at pace (the clock keeps running). A growing - archive is followed as new timeline segments land. A finite archive ends - each track after its last group. -- FETCH keeps serving any advertised group, so DVR and HLS are unaffected. -- The API shape (a reader option vs a separate paced publisher) and the CLI + stored catalog group emitted at its recorded time, so a viewer sees the + state that applied then. Test a stored broadcast that ends muted or with a + rendition removed. At equal timestamps the catalog goes first, so a media + group never arrives before the catalog update that lists it. +- One clock per import, not per subscriber. It starts at the earliest stored + timestamp across tracks and every track paces against it, so tracks stay in + sync and every viewer sees the same moment. +- Decided 2026-10-17: untimed tracks are not paced. They stay readable by + request and range, since they have no media time to pace by; only timed + tracks pace. Rejected: pacing an untimed track by its neighbours. +- A gap is skipped at pace (the clock keeps running). A growing archive is + followed as new keys land. A finite archive ends each track after its last + group. +- Requests for past groups keep answering, so DVR and HLS are unaffected. +- The API shape (a consumer option vs a separate paced publisher) and the CLI default follow the root API rules; report both in the PR. Update `doc/bin/cli.md`. -- Test with paused time: record, replay paced, and assert a plain subscriber +- Test with paused time: store, replay paced, and assert a plain subscriber gets every group in order at media pace, that a late subscriber starts at the current group, and that two tracks stay aligned. -- Deferred in the 2026-09-30 audit and moved to m3 in the 2026-10-05 audit: no named consumer; FETCH replay - already serves DVR and HLS. +- Deferred in the 2026-09-30 audit and moved to m3 in the 2026-10-05 audit: no + named consumer. ## Required -- [Replay catalog](/quest/m1/archive/replay-catalog.md) - publishes the recorded catalog live, which a live player needs before any paced media +- [Archive consumer](/quest/m1/archive/consumer.md) - the stored broadcast this paces