Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ Routing: Wildcard landed in #4403, so a service claims the prefix it could
serve instead of enumerating broadcasts. Serving the relay's ingested-only
view (`origin::Consumer::local()`) to localhost workers belongs to moq.pro's
edge, which embeds moq-relay; it moved there on 2026-09-28.
Pools of claim workers (transcoders) also need the relay to forget a front
nobody reads, and demand that doesn't flap on a viewer's first subscribe
(both found 2026-10-07).

Interop: Fastly's moq-relay-interop report (run of 2026-09-23, build
7ee2b02) was triaged against `main` on 2026-10-07. Its SETUP, UNSUBSCRIBE
Expand Down Expand Up @@ -60,6 +63,9 @@ a published `@moq/watch` break.
- [Subgroup at object 0](/quest/m0/ietf-first-object-zero.md) - a draft-18 subgroup with FIRST_OBJECT clear that starts at object 0 is read whole
- [Request caps](/quest/m0/request-caps.md) - lite message sizes, IETF request IDs, and per-session announces and subscriptions are bounded
- [Prefix route fronts](/quest/m0/prefix-route-fronts.md) - a prefix route cannot be made to mint one front per requested path
- [TRACK stream demand](/quest/m0/track-stream-demand.md) - a lite-05+ subscribe shows the publisher one `used` edge, not a flap between TRACK and SUBSCRIBE
- [Idle fronts](/quest/m0/idle-fronts.md) - a front nobody has read for the linger ends with its per-path state, so a standing claim stops accumulating fronts and sources
- [Upstream position regression](/quest/m0/largest-regression.md) - a relay copy that sees upstream's largest group go backwards ends instead of serving the old instance's cache
- [qmux credit](/quest/m0/qmux-credit.md) - qmux returns connection credit for dropped and stopped streams and delivers its close frame, on both lines
- [web-transport releases the qmux fixes](/quest/m0/qmux-credit-upstream.md) - waiting on moq-dev/web-transport#412 and #413 to merge and ship, which qmux credit bumps to
- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - every first-party publisher that can restart mints a fresh route epoch, the newest wins a path, and only routes with the same epoch resume a subscription
Expand Down
4 changes: 4 additions & 0 deletions quest/m0/broadcast-epoch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ Decided:
explicit `Restart` announce event tells players to follow, through
[Restart](/quest/m0/broadcast-epoch/restart.md). When the newest goes and
an older one is still live, the older one wins again as a new broadcast.
- On lite-07 a claim's answer will carry the served broadcast's own epoch
([Claim-served epochs](/quest/m0/broadcast-epoch/claim-epochs.md), planned
2026-10-07), which adds it to TRACK_INFO.
- A catalog `broadcast` reference by name follows the newest epoch, since a
path cannot name one.
- Every first-party publisher that can restart mints its own: the apps,
Expand Down Expand Up @@ -95,4 +98,5 @@ then. MoQ Pro's VOD `storage.json` moves from the same seed to its own epoch.
- [Publish catalog restart](/quest/m0/broadcast-epoch/publish-catalog-restart.md) - `@moq/publish` never reuses catalog group numbers under one name and epoch after a re-announce
- [TS restart](/quest/m0/broadcast-epoch/ts-restart.md) - a signalled backward TS discontinuity finishes the broadcast and continues the same input under a fresh epoch
- [Bindings](/quest/m0/broadcast-epoch/bindings.md) - moq-ffi and every wrapper expose the epoch and let a publisher announce one
- [Claim-served epochs](/quest/m0/broadcast-epoch/claim-epochs.md) - a claim's answer names the instance that served it, so a restarted output is never spliced and a per-output epoch costs no restart
- [Stats totals and prefix tracks](/quest/m0/broadcast-epoch/stats-split.md) - the same release retires the per-path stats maps for totals and on-demand prefix tracks (decided 2026-10-05)
94 changes: 94 additions & 0 deletions quest/m0/broadcast-epoch/claim-epochs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# [L] A claim's answer carries its broadcast's epoch

## Goal

A broadcast a worker serves in answer to a prefix claim carries its own
epoch, and a relay learns it from the answer. Two things follow for a pool
of claim workers (transcoders) behind relays:

- A worker that closes an output and serves the path again is a new instance,
so a relay never splices the closed instance's cached groups into it.
- A per-output epoch costs no cut on the first view.

Today a claim names no instance, a relay's session answers a request under a
claim with a placeholder source before anything goes upstream, and no answer
on the wire names an instance, so the relay's front always resolves without
an epoch. A worker that announces its output's exact path with a fresh epoch
to get one restarts the first viewer: the front resolved through the claim,
and the epoch route wins the path and is announced as a `Restart`
([Restart](/quest/m0/broadcast-epoch/restart.md)). A front on a drained
worker no longer needs this: under Restart a request joins a front only while
its route still wins, on every version (re-scoped 2026-10-07). lite-07 only; older versions
and moq-transport keep today's behavior.

## Plan

Decisions (2026-10-07, proposed for the maintainer):

- TRACK_INFO gains an `Epoch`: the instance that answered, or empty. Each hop
answers with the epoch of the front it served from, so it carries through
relay chains. SUBSCRIBE_OK doesn't need it: the relay fills the learned
epoch into later TRACK, SUBSCRIBE and FETCH, and the publisher's existing
refusal of another epoch catches a restart.
- The epoch lives on the broadcast, since a broadcast is an instance, and
`Request::accept` reads it from the accepted broadcast rather than taking
it as an argument.
- A front adopts the epoch it learns. Routes without an epoch never splice
(maintainer, 2026-10-08, in [Restart](/quest/m0/broadcast-epoch/restart.md)):
a route change, including a per-path winner change under a prefix pool,
reaches downstream as a `Restart` of the route, which unsets the cached
copy of every broadcast nested under it. What that
can't see is a worker restarting an output under the same claim route;
there the learned epoch is what a downstream copy names on re-subscribe,
and a mismatch is refused.
- A refusal of a front's learned epoch means that instance is gone: the front
ends, and a request that joined it re-resolves onto a fresh front (a new
TRACK_INFO exchange) instead of failing. Only readers the old instance was
serving see a cut.
- A front someone reads stays on its instance even when a cheaper route
appears, as Restart's sticky subscriptions already do: moving it would
start a second instance at the path. A worker that wants its viewers off
closes its outputs, and they re-request.
- No dedicated "broadcast closed" message: with the epoch, the next request
re-resolves anyway, and an exact announcement already gets ANNOUNCE_END.
- The draft's "the Epoch of the route it resolved" and "the route it would
serve from has another Epoch" become broadcast-scoped; as written, a
request naming an epoch is refused while the best route is the claim, even
though that instance is serving. Update the concept doc's epoch section and
this line's README to match.
- A worker using this should not also announce its output's exact path with
the epoch: that announcement and TRACK_INFO race on separate streams, and
the announcement still wins new requests over the claim front and is
announced as a `Restart`.
- A worker drains feed by feed by closing the outputs it wants off: their
readers re-request and land on the cheapest worker as a new instance, one
short cut per feed instead of withdrawing the claim and cutting everything.
- Every link from the worker to the relays that serve viewers must speak
lite-07, since the epoch is learned and named hop by hop. A viewer's own
version should not matter, because the join rule and the refusal live at
relays and workers; confirm it, including that `@moq/net`'s track cache
never hands a returning viewer the old instance's group.

This is a lite-07 wire change, so it lands before the version is cut.

Verification: a relay integration test with two claim workers (mocked time).
A worker restarting an output within the linger delivers the new instance from
its first group, with nothing stale. A worker answering with a per-output
epoch serves its first viewer without a cut. A worker closing a path while
it is read sends the re-request to the cheaper worker as a new instance. A
request joining an unread front whose worker restarted its output reaches the
new instance without an error. A lite-06 or IETF viewer behind a lite-07
worker-to-relay path, with the old instance's groups still cached, receives
only the new instance's groups after the worker restarts the output with a
new epoch.
`just test interop --all`.

Public API: Rust and JS, the broadcast's epoch and what `accept` does with
it. Wire: lite-07 TRACK_INFO gains `Epoch`.

## Related

- [Restart](/quest/m0/broadcast-epoch/restart.md) - the join rule and sticky subscriptions this builds on
- [Idle fronts](/quest/m0/idle-fronts.md) - ends an unread front and its per-path state after the linger on every version
- [Upstream position regression](/quest/m0/largest-regression.md) - catches a restart on versions without this field
- [Finalize moq-lite-07](/quest/m1/lite07-finalize.md) - waits on this wire change
29 changes: 26 additions & 3 deletions quest/m0/broadcast-epoch/restart.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ decide. A copy resumes or splices only across routes with an
identical epoch, so the old copy's cached groups never reach a subscriber of
the new source.

Relays keep caching between subscriptions: a `Restart` is the cache
invalidation. It unsets the relay's copy for that path, so the next
subscription goes upstream to the new route (maintainer, 2026-10-08: "a
Restart unsets the cache; the next subscription goes upstream to the new
route").

`Restart` is route-level only, on every version, lite-07 included
(maintainer, 2026-10-08: "Restart is route-level only; nested broadcasts
under the longest-matching route restart"). A `Restart` names a route, the
announced path or prefix, as ANNOUNCE does. A subscriber applies it to every
broadcast whose longest matching route is that one: it unsets each cached
copy, and the next subscribe re-resolves upstream. Under a prefix pool the
advertised winner is ranked by the prefix while requests are ranked by the
requested path, so a path can move to another worker while the prefix's
winner stays. The relay sends that as a `Restart` of the prefix route (such
as `pool`), and every nested broadcast under it re-resolves; a viewer whose
path kept its worker just resubscribes to the same source.

## Plan

Decided in planning (2026-10-07, from #4970's review):
Expand Down Expand Up @@ -88,7 +106,8 @@ Decided in planning (2026-10-07, from #4970's review):
relay retires that copy for new requests and opens a fresh one, never
joining or resuming into the old copy.
- **Wire.** lite-07 (`moq-lite-07-wip`) gains an explicit restart message on
the announce stream. Older lite versions and moq-transport send
the announce stream, naming a route like ANNOUNCE does, never a nested
path. Older lite versions and moq-transport send
`ANNOUNCE_END` then `ANNOUNCE_START` for a source change, a new rule for a
relay's winner moving between entries (the draft only says so for a
publisher replacing its own epoch), and a receiver delivers an end and
Expand Down Expand Up @@ -140,8 +159,12 @@ old route: no `Restart` and new requests stay on the old route until it is
withdrawn, then `Restart` and a re-request lands on the replacement. With
mocked time, a lite-06 relay chain where an epochless publisher restarts: the
downstream relay's cached groups from the old instance never reach a new
subscriber mixed with the new instance's groups. Run `just drafts check` and
`just test interop --all`.
subscriber mixed with the new instance's groups. With mocked time, on lite-06
and lite-07, a prefix pool of epoch-less workers announced as `pool` behind
two relays, where one path's winner changes under an unchanged prefix winner:
the downstream relay gets a `Restart` of `pool`, drops its cached copies of
the nested paths, and the next subscribes reach the right workers, the moved
path on its new worker and the rest on theirs. Run `just drafts check` and `just test interop --all`.

Docs: update `doc/concept/moq-lite.md` (publisher epochs) and
`doc/lib/{rs,js}` announce sections inline, plus `doc/bin/rtmp.md`,
Expand Down
101 changes: 101 additions & 0 deletions quest/m0/idle-fronts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# [M] An unread front ends after its linger

## Goal

A relay ends an origin front once nothing has read it for `track::IDLE_LINGER`,
and the per-path state it holds goes with it, so a standing prefix claim
doesn't keep a front, its driver task, and a session placeholder source for
every path ever requested under it. Today a front lives until its route
leaves. Done when, after the linger, the relay holds no front for an unread
path P and the session no source for it.

Found with a pool of transcode workers claiming one prefix with
`origin::Producer::dynamic`: a drained worker kept serving a path to new
viewers through a relay. That routing half is
[Restart](/quest/m0/broadcast-epoch/restart.md)'s (re-scoped 2026-10-07): a
request joins a front only while its route still wins, so a drained claim's
front takes no newcomers. A replaced front still keeps serving its
subscribers until its route goes, so this quest reclaims it once unread.

Non-goal: a worker that closes an output and serves the same path again
restarting at group 0. That reuses a name for different content, which is the
publisher's bug (see Plan).

## Plan

Facts from `main` (`rs/moq-net/src/model/origin.rs`, `front.rs`,
`lite/subscriber.rs`):

- A front resolved without an epoch stays on its first route (`pick`, #4942).
`request()` joins any live front whose epoch matches the best route's until
[Restart](/quest/m0/broadcast-epoch/restart.md) lands; after it, a front
whose route lost takes no newcomers but stays for its subscribers.
- Under a claim, the session answers a request with a placeholder source it
creates on the spot (`poll_serve`), kept in the announced route's sources
until the claim is withdrawn or the session closes. moq-lite has no message
for "the broadcast served under this prefix closed", so the relay never sees
`SourceClosed` when the worker closes its output. In-process (no relay) the
front's source is the worker's broadcast, `SourceClosed` fires, and the next
request re-resolves; that's why the bug needs a relay.
- Nothing ends a front for having no readers. Its unread tracks park and are
forgotten after `IDLE_LINGER`, but the front and its driver task stay.
- Each distinct path requested under a claim therefore leaves a placeholder
source and serve state on the session, and a front on the relay, for as
long as the claim stands.

Decisions (2026-10-07):

- End every front, epoch or not, once all its tracks are forgotten and no
consumer holds its broadcast. Ending only after the linger keeps the cache
window for a returning viewer, needs no wire change, and doesn't conflict
with the draft's Resume text, which pins subscriptions, not idle fronts.
A front serving a local source forgets an unread track outright, so it ends
as soon as it goes unread; reaching a local source again is free.
- A request must never join a front that is ending: the front leaves the
origin's front table under the lock before it ends (or `request()` treats an
ending front as gone), so a newcomer gets a fresh front rather than a
broadcast that closes at once.
- A front still waiting for coverage
([Front parking](/quest/m1/origin-front-parks.md)) is not idle and stays.
- The session drops a placeholder source once it has no consumers left, so
per-path state under a claim goes with its fronts instead of piling up until
the claim leaves. The route's served cache hands one source to every front
for the path (a plain front and a peer's filtered front can share it), so
the trigger is the source losing its last consumer, not one front ending.
Withdrawing the claim or closing the session still closes the source at
once, as today, and in-flight consumers drain on their own. The session
keeps that state in more than one place; all of it goes.
- This also reclaims the filtered front a peer session leaves behind (moved
here from [Front parking](/quest/m1/origin-front-parks.md)): a hop in a
covering route chain gets its own filtered front, which today lives as long
as the route, one per peer session that both publishes and subscribes or
reconnects with a fresh hop.
- A claim worker that re-serves a path after closing it is a new instance.
Document that in `doc/concept/moq-lite.md` Resume: such a worker keeps its
group sequence going, across its own source restarting too (mirroring an
upstream sequence that goes back to 0 isn't enough), or gives each output its own epoch once
[Claim-served epochs](/quest/m0/broadcast-epoch/claim-epochs.md) lands.
Without that, a viewer returning within the
linger gets the old instance's cached latest group and then nothing until
the new instance's sequence passes it.

Verification: a relay integration test with two `dynamic` claims on their own
sessions (mocked time): read P, leave, close the output, drain the serving
claim, advance past the linger, and check that the relay holds no front for P
and the first session no source for P. A `front.rs`
unit test for the end condition, including a parked front that must stay,
and an origin test of a request racing a front's end (like `front.rs`'s
`a_reader_racing_the_forget_keeps_the_track`, one level up). The front count
returns to the plain front after a peer session closes.

Public API: none expected. Wire: none.

## Related

- [Restart](/quest/m0/broadcast-epoch/restart.md) - a request joins a front only while its route still wins, the routing half of the same report
- [Claim-served epochs](/quest/m0/broadcast-epoch/claim-epochs.md) - on lite-07, a worker's restarted output is a new instance and never spliced
- [Upstream position regression](/quest/m0/largest-regression.md) - fails loud on the stale splice where the answer shows it
- [Prefix route fronts](/quest/m0/prefix-route-fronts.md) - bounds how many fronts a prefix route can mint at once; this reclaims idle ones
- [Front parking](/quest/m1/origin-front-parks.md) - a front waiting for coverage must survive this; the filtered-front leak moved here from it
- [Route wakes](/quest/m1/route-wakes.md) - indexes fronts per route, so an ended front must drop its entries
- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - restarted publishers mint a fresh epoch, the documented fix for a re-served path
Loading
Loading