Skip to content
27 changes: 15 additions & 12 deletions doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,24 +197,27 @@ Each subscription carries the knobs that decide behavior under congestion:
| --- | --- |
| **Priority** (0..255) | Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement. |
| **Order** | Which group to send first when several are pending. Newest first for live (the default), oldest first for catch-up. Needs moq-lite 07 or moq-transport; older moq-lite always sends newest first. |
| **Max delay** | How far a non-latest group may fall behind the live edge before it is skipped. Zero means "live edge only", and raising it is also what asks for history. |
| **Max delay** | How long a non-latest group stays worth delivering before it is skipped. Zero means "live edge only", and raising it is also what asks for history. |

Order only decides what goes first, never what is skipped, so catching up oldest
first needs a max delay that covers the backlog. When a relay serves subscribers of
both orders from one upstream subscription, it asks upstream for newest first, and
still sends each subscriber its groups in that subscriber's own order. Over
moq-transport the upstream order is fixed by whichever subscriber arrived first.

Max delay is measured on the media timeline, not the wall clock, so a backlog
delivered as a burst is still late while a congestion stall never expires
anything on its own. Both ends apply it: the publisher skips a group rather
than sending it, and the subscriber skips it again as it reads, since the
publisher only ever sees the most tolerant budget across its subscribers.
Max delay starts counting when a group's successor arrives, on two clocks, and
whichever reaches it first skips the group: the wall clock since the successor
arrived at this hop, and how far the successor's first timestamp trails the
live edge. Media time catches a backlog delivered as a burst; the wall clock
catches a publisher whose timestamps stall and an untimed track. The newest
group has no successor, so it is never skipped. Both ends apply it: the
publisher skips a group rather than sending it, and the subscriber skips it
again as it reads, since the publisher only ever sees the most tolerant budget
across its subscribers.

A track is timed or untimed: it declares a timescale or none, and a frame that
doesn't match is refused. No receiver fills in an arrival time. An untimed
group never falls past max delay, so a new subscriber starts at its latest
group unless it names a start. Tracks from moq-lite before 05, or from
doesn't match is refused. No receiver fills in an arrival time, so an untimed
track is judged by the wall clock alone. Tracks from moq-lite before 05, or from
moq-transport without a `TIMESCALE` (every track on drafts 14-16), arrive
untimed. moq-lite 05 and 06 cannot declare an untimed track, so there its
frames go out stamped with their send time; the work-in-progress 07 carries it
Expand All @@ -228,9 +231,9 @@ nothing from it until the source answers again.
The publisher may declare a retention window per track. Omission sets no limit;
zero keeps only the live edge. Relays preserve that declaration, and may still
evict sooner under their own cache ceiling. Media tracks explicitly use 30
seconds so a segmented egress can still find its segments. Retention uses media
timestamps and always keeps the newest group. IETF carries the same idea as
`MAX_CACHE_DURATION`, measured in wall time, which is only an approximate match.
seconds so a segmented egress can still find its segments. Retention ages a
group by the same two clocks as max delay and always keeps the newest group.
IETF carries the same idea as `MAX_CACHE_DURATION`, measured in wall time alone.

Put together, a conference might use:

Expand Down
2 changes: 1 addition & 1 deletion doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ broadcast.announce();
- **One send estimate per connection.** `Bandwidth.Allocator` divides it by track priority, max-min fair within a tier. An idle track claims nothing. Publishers reserve against it so their targets sum to the estimate.
- **One track per name.** Concurrent subscriptions share one request and producer. `createTrack` on a name with a request still queued answers it. The application owns sequence numbers: a track that replaces an ended one, created or requested, throws on an append that would reuse a sequence the name already wrote, so it writes its first group with an explicit sequence. Only a new broadcast starts over.
- **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers.
- **Subscriber staleness is `maxDelay`.** It is media time. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`.
- **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`.
- **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts).
- **Authorization is in band.** On moq-lite 07 (offered only when `moq-lite-07-wip` is listed in `webtransport.protocols`; the WebSocket fallback cannot offer it) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up.
- **A graceful close waits.** `await connection.close()` withdraws announcements and gives finished tracks up to one second. `abort()` ends immediately.
Expand Down
8 changes: 8 additions & 0 deletions doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ These land with the next breaking release, not the 2026-09-23 train.
now take effect on parsed and merged configs unless the flag or environment
variable was actually given. TOML keys, C ABI, binding records, and wire
formats are unchanged.
- **Max age and max delay count wall-clock time too.** A superseded group is
stale once its successor arrived a full budget ago, as well as by media time,
so an untimed track is no longer exempt: with the default zero `max_delay`,
an untimed subscriber starts at the latest group and skips each superseded
one. Give it a budget to replay or tolerate reordering.
- **`cache::Pool::gc` takes a waiter.** In Rust, `gc(now)` is
`gc(now, waiter)`, like `Driver::poll`: call it again when the waiter wakes,
since a parked read can arm an earlier deadline than the one returned.
- **A QUIC load-balancer nonce needs its server id.** `listen.lb_nonce`,
`--listen-quic-lb-nonce`, and `MOQ_LISTEN_QUIC_LB_NONCE` without `lb_id` stop
startup. Setting `lb_id` (or `lb_nonce`) and `load_balancer` together also
Expand Down
29 changes: 17 additions & 12 deletions drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -632,33 +632,38 @@ A subscriber MUST support gaps and out-of-order delivery regardless.

## Expiration
Expiration governs when an older group is dropped.
The publisher SHOULD reset Group Streams for non-latest groups whose age relative to the latest group reaches `Subscriber Max Age` (see [SUBSCRIBE](#subscribe), and the age definition below); the subscriber MAY also locally drop such groups.
The publisher SHOULD reset Group Streams for non-latest groups whose age reaches `Subscriber Max Age` (see [SUBSCRIBE](#subscribe), and the age definition below); the subscriber MAY also locally drop such groups.
Expiration only removes the group from live delivery; the publisher MAY still retain it for FETCH or new subscriptions until its age exceeds `Publisher Max Age` (see [TRACK_INFO](#track-info)).

It is not crucial to aggressively expire groups thanks to [prioritization](#prioritization), but a lower priority group still consumes RAM, bandwidth, and potentially flow control.
It is RECOMMENDED that an application set conservative limits and only resort to expiration when data is absolutely no longer needed.

A group is never expired until a later group (by sequence number) has presented a frame.
A group of an untimed Track has no timestamps, so it is never expired.
Once one has, the group's **timestamp age** is the difference between the *newest* frame timestamp of the latest group that has at least one frame, and this group's **reach**, defined below; the group is expired once that age meets the relevant `Max Age`.
Timestamps are the only measure: they are consistent across relays and unaffected by buffering or jitter.
Wall-clock reclamation of idle content is the retention cache's own policy (`Publisher Max Age`, or any implementation-defined bound), not part of this rule.
A group is never expired until its **successor** has arrived: the next group by sequence number that has arrived at this hop and was not reset.
The latest group has no successor, so it is never expired.
A group is expired once either of two ages meets the relevant `Max Age`:

A group is expired only once it **provably cannot overlap the Max Age window**: every frame it could still present is older than the budget allows.
- Its **timestamp age** is the difference between the *newest* frame timestamp of the latest group that has at least one frame, and this group's **reach**, defined below.
- Its **arrival age** is the time elapsed since its successor arrived at this hop.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

A group of an untimed Track has no timestamps, so arrival age alone expires it.

By timestamp age, a group is expired only once it **provably cannot overlap the Max Age window**: every frame it could still present is older than the budget allows.
Arrival age needs no such proof: it bounds how long a superseded group may still wait to be delivered.
Being behind is not itself a reason to expire anything.
[Prioritization](#prioritization) already transmits newer groups first, so an older group consumes only whatever capacity is left over; it therefore closes the gap faster than the live edge advances, and a receiver that is behind can converge without losing content.
What cannot be recovered is a group with nothing left worth delivering, and that is what expiration removes.

A group's **reach** is the first frame timestamp of the next group by sequence number, or unbounded when no later group has presented a frame.
A group cannot present past where its successor begins, so this is the furthest it could still reach.
Its own frames do not bound it: frame durations are not carried on the wire, so a group's last frame timestamp is where that frame *starts* presenting, not where the group ends.
A group whose successor has not presented a frame is therefore never expired, because nothing yet proves where it stops.
A group whose successor has not presented a frame therefore has no timestamp age, because nothing yet proves where it stops.
The group itself needs no timestamp: a zero-frame group (a keep-alive or gap marker) is bounded by its successor's first frame the same way.

Reach is an exclusive bound, so a timestamp age **equal** to Max Age already expires the group: the freshest frame it could still hold sits strictly below its reach, and is therefore strictly older than the budget.
A Max Age of zero follows from the same rule without a special case.

Measuring in timestamps rather than arrival means a burst of groups delivered together still reads as its true age, so catching up never resets the clock.
Timestamp age means a burst of groups delivered together still reads as its true age, so catching up never resets the clock.
Arrival age bounds what timestamps cannot: a publisher whose timestamps stop advancing, a successor that never presents a frame, an untimed Track, and a run of empty groups.

An expired group SHOULD be reset at the QUIC level to avoid consuming flow control.

Expand Down Expand Up @@ -1118,7 +1123,7 @@ See the [Group Order](#group-order) section for more information.

**Subscriber Max Age**:
The subscriber's preference, in milliseconds, for how long a non-latest group may remain in flight before being considered stale and dropped from live delivery.
The publisher SHOULD reset (at the QUIC level) Group Streams for groups whose age relative to the latest group exceeds this duration.
The publisher SHOULD reset (at the QUIC level) Group Streams for groups whose age, as defined in [Expiration](#expiration), reaches this duration.
Applies only to non-latest groups; the latest group is never dropped on staleness grounds.
A value of `0` means the subscriber wants only the latest group in live delivery (older groups are immediately stale once a newer group arrives).
This is a delivery-time preference, not a retention rule: the publisher MAY still hold these groups for FETCH or future subscriptions (see `Publisher Max Age` in [TRACK_INFO](#track-info)).
Expand Down Expand Up @@ -1221,7 +1226,7 @@ The publisher's priority for this Track, represented as a u8, used only to resol
See the [Prioritization](#prioritization) section for more information.

**Publisher Max Age**:
The maximum age, in milliseconds, that the publisher caches a non-latest group past the arrival of a newer group.
The maximum age, in milliseconds, that the publisher caches a non-latest group, with age as defined in [Expiration](#expiration).
Applies only to non-latest groups; the latest group is always retained.
It is an upper bound on retention, the inverse of an HTTP `Cache-Control: max-age` guarantee:

Expand Down Expand Up @@ -1568,7 +1573,7 @@ The `Message Length` describes the payload size on the wire.
- Added `Epoch` to ANNOUNCE_START, SUBSCRIBE, TRACK, and FETCH: a UUIDv7 naming the publisher instance, or empty. A path and an Epoch name one Broadcast. A relay MAY move a subscription between routes with the same Epoch, continuing from the first frame the subscriber lacks instead of at a group boundary, and never between routes with different Epochs or none. The newest Epoch wins a path for new requests, while subscriptions to the older one stay until their route goes. Replaces the first-hop identity.
- Added ANNOUNCE_RESTART (0x3): another publisher instance replaces a live advertisement, under a new Epoch or, without one, through another route, including a path beneath a prefix moving to another route. Older versions send ANNOUNCE_END then ANNOUNCE_START for it.
- An untimed Track declares a `Timescale` of 0 in TRACK_INFO, and its FRAME messages and datagrams omit the timestamp. lite-05 and lite-06 publishers declare 1000 for it and send each payload's send time instead.
- A group of an untimed Track is never expired.
- Expiration also measures a group's arrival age, the time since its successor arrived at this hop, so a group of an untimed Track expires by arrival age alone.
- An open Track Stream is interest in the Track until the subscriber closes it, which a subscriber does once its Subscribe Stream has a response. lite-05 and lite-06 implementations do the same.

- Assigned `moq-lite-07-wip` as this draft's protocol identifier until it is finalized as `moq-lite-07`.
Expand Down
7 changes: 3 additions & 4 deletions drafts/draft-lcurley-moq-timestamp.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ A track is either timed or untimed for its whole lifetime; no track mixes timed
A receiver MUST NOT substitute a time for an untimed object, such as its arrival time, and a relay MUST forward an untimed track untimed, declaring no TIMESCALE downstream.
Arrival time differs at every hop and changes when a route fails over, so a substituted time would not be the same timeline downstream.

Age-based dropping ({{age}}) never drops an untimed object; a relay bounds how long it keeps one with its own cache policy.
A new subscription to an untimed track that names no start begins at the latest group, since no age says how far back to reach.
Age-based dropping ({{age}}) does not apply to an untimed object.
A relay MAY instead drop a group of an untimed track once a later group arrived at it longer ago than the threshold, measured on its own clock.

## Age-Based Dropping {#age}
Given two objects on the same track, both with TIMESTAMP, a relay computes their relative age as the difference of their timestamps divided by the Timescale.
Expand Down Expand Up @@ -162,10 +162,9 @@ An earlier version of this document requested its own code points (`0x915C0` and
## moq-timestamp-02
{:numbered="false"}

- Replaced the wall-clock arrival-time fallback with untimed tracks: a track without TIMESCALE has no media time, its objects are never dropped for age, and a relay forwards it untimed.
- Replaced the wall-clock arrival-time fallback with untimed tracks: a track without TIMESCALE has no media time, and a relay forwards it untimed. A relay MAY drop one of its groups once a later group arrived longer ago than the threshold.
- On a track with TIMESCALE, a publisher MUST attach TIMESTAMP to every object with a payload, and a receiver treats one without it as malformed.
- A receiver ignores an object-scope TIMESCALE, and no longer reads a bare TIMESTAMP on a track without TIMESCALE as microseconds.
- A new subscription to an untimed track that names no start begins at the latest group.

# Acknowledgments
{:numbered="false"}
Expand Down
8 changes: 8 additions & 0 deletions js/net/src/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ class GroupState {
timestamp?: Timestamp;
// The newest frame's timestamp: where a reader that has taken every frame sits.
latest?: Timestamp;
// When the group joined the track this mirror belongs to, at this hop, in
// `performance.now()` milliseconds: the wall clock a superseded predecessor ages from.
// Per track, since one group can join several.
arrived = 0;

constructor(sequence: number) {
this.sequence = sequence;
Expand Down Expand Up @@ -428,6 +432,10 @@ export class Consumer {
makeConsumer = (state) => new Consumer(state);
hooks.groupTimestamp = (group) => group.#state.timestamp;
hooks.groupLatest = (group) => group.#state.latest;
hooks.groupArrived = (group) => group.#state.arrived;
hooks.arriveGroup = (group, at) => {
group.#state.arrived = at;
};
hooks.expireGroup = (group, expiry) => {
group.#expiry = expiry;
};
Expand Down
9 changes: 6 additions & 3 deletions js/net/src/ietf/subscriber.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createMockTransportPair } from "../mock.ts";
import * as Path from "../path.ts";
import { Reader, Stream } from "../stream.ts";
import { Tail } from "../tail.ts";
import { Timescale, Timestamp } from "../time.ts";
import { Milli, Timescale, Timestamp } from "../time.ts";
import type * as track from "../track.ts";
import { ControlStreamAdapter, NativeSession } from "./adapter.ts";
import type * as Cluster from "./cluster.ts";
Expand Down Expand Up @@ -960,15 +960,17 @@ function encodeObjects(deltas: number[]): Uint8Array {
async function subscribeTrack({
version = VERSION,
properties = {},
subscription,
}: {
version?: IetfVersion;
properties?: Properties;
subscription?: track.Subscription;
} = {}): Promise<{ subscriber: Subscriber; track: track.Subscriber }> {
const pair = createMockTransportPair(version === Version.DRAFT_16 ? ALPN.DRAFT_16 : ALPN.DRAFT_19);
const session = new NativeSession(pair.server, version, true);
const subscriber = new Subscriber({ session });

const track = subscriber.consume(Path.from("room")).track("video").subscribe();
const track = subscriber.consume(Path.from("room")).track("video").subscribe(subscription);

const peer = await nextStream(pair.client);
if (!peer) throw new Error("the subscriber never opened a subscribe stream");
Expand Down Expand Up @@ -1447,7 +1449,8 @@ test.each([
[VERSION, true, [3, 4]],
[Version.DRAFT_16, true, [3, 4]],
])("draft %s with FIRST_OBJECT=%s: a header-only stream delivers %p", async (version, firstObject, expected) => {
const { subscriber, track } = await subscribeTrack({ version });
// A budget, so the empty group outlives its successor's arrival.
const { subscriber, track } = await subscribeTrack({ version, subscription: { maxDelay: Milli(60_000) } });

await subscriber.handleGroup(
new GroupMessage({
Expand Down
Loading
Loading