Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
955969b
quest(m1): replace SUBSCRIBE_ALL with a TRACKS listing
kixelated Oct 19, 2026
7125277
quest(m1): TRACKS carries TRACK_INFO, caught-up, and removal events
kixelated Oct 19, 2026
578ceb0
Merge remote-tracking branch 'origin/main' into quest-plan/archive-tr…
kixelated Oct 19, 2026
3958a17
quest(m1): TRACKS caught-up is hop-local
kixelated Oct 19, 2026
0d9e592
quest(m1): TRACKS signals caught-up in order; only TRACK_ENDED removes
kixelated Oct 19, 2026
7e1040a
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/archive…
kixelated Oct 19, 2026
4f0c316
quest(m1): TRACKS removes by name, CAUGHT_UP replaces Active Count
kixelated Oct 19, 2026
3be0637
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/archive…
kixelated Oct 19, 2026
11fbc1a
quest(m1): a skipped IETF track is still listed
kixelated Oct 19, 2026
8df1cc3
quest(m1): answer SUBSCRIBE_TRACKS by FORWARD, resume the held PUBLISH
kixelated Oct 19, 2026
dfad6d0
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/archive…
kixelated Oct 19, 2026
75a338a
quest(m1): refuse draft-18 forwarding listings, keep TRACKS refusal r…
kixelated Oct 19, 2026
60dd16e
quest(m1): one SUBSCRIBE_TRACKS per session, widened in place
kixelated Oct 19, 2026
a6c8499
quest(m1): strict prefix overlap, siblings get their own SUBSCRIBE_TR…
kixelated Oct 19, 2026
e1f1d3a
quest(m1): SYNCED on first drain, per-reader queues, reset on refused…
kixelated Oct 19, 2026
9483d0e
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/archive…
kixelated Oct 19, 2026
5c0c1e8
quest(m1): a refused widen resets the widened listing too
kixelated Oct 19, 2026
f1fa395
quest(m1): SYNCED at first drain or after the snapshot's N events
kixelated Oct 19, 2026
f65c874
quest(m1): adopt an existing SUBSCRIBE on every draft, frame index in…
kixelated Oct 19, 2026
99585e5
quest(m1): datagrams arrive from the per-track SUBSCRIBE on
kixelated Oct 19, 2026
8cf1ed4
quest(m1): a TRACKS removal promises only that the track left the lis…
kixelated Oct 19, 2026
381df2d
Merge origin/main
kixelated Oct 19, 2026
a486488
quest(m1): SUBSCRIBE_TRACKS never widens in place
kixelated Oct 19, 2026
f817fda
quest(m1): archive keeps datagram form and end markers, cancels close…
kixelated Oct 19, 2026
85b6049
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/archive…
kixelated Oct 19, 2026
0d95fd5
quest(m1): archive markers record errors and sort where refresh finds…
kixelated Oct 19, 2026
539ad64
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/archive…
kixelated Oct 19, 2026
bd5c3fa
quest(m1): archive markers wait for the broadcast listing to end
kixelated Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
68 changes: 68 additions & 0 deletions quest/m1/announce-synced.md
Original file line number Diff line number Diff line change
@@ -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
39 changes: 23 additions & 16 deletions quest/m1/archive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 `<prefix>/<track>/<first group>-<first pts>`
(`<first group>` for an untimed track, `<track>` percent-encoded as one
objects cut by duration, keyed
`<prefix>/<track>/<first group>-<first frame>-<first pts>` (`<first group>`
for an untimed track, `<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
Expand Down Expand Up @@ -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

Expand Down
27 changes: 24 additions & 3 deletions quest/m1/archive/consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.

Expand Down
Loading