Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f4c086d
quest(m1): plan the hidden floor below a resolved relay copy
kixelated Oct 19, 2026
f32585e
quest(m1): resolved demand is compare-only
kixelated Oct 19, 2026
5e7d0d3
Merge remote-tracking branch 'origin/main' into quest-plan/resolved-d…
kixelated Oct 19, 2026
4537e3c
quest(m1): resolved demand covers js/net
kixelated Oct 19, 2026
00e9eb0
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/resolve…
kixelated Oct 19, 2026
cd54887
quest(m1): a widened max age re-offers cached groups
kixelated Oct 19, 2026
299abbd
quest(m1): evicted groups are a range miss, not a re-offer
kixelated Oct 19, 2026
d81db75
quest(m1): re-offer follows the subscription's order
kixelated Oct 19, 2026
6a8476d
quest(m1): re-offer when a stale verdict loses its edge
kixelated Oct 19, 2026
fc7df6d
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/resolve…
kixelated Oct 19, 2026
f25e306
quest(m1): a widening outranks a reported drop
kixelated Oct 19, 2026
938767d
quest(m1): scope drop re-offers to lite-07, relays re-judge stale drops
kixelated Oct 19, 2026
ccf7ab0
quest(m1): drops are final for now, plan the drop set
kixelated Oct 19, 2026
68b64b2
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/resolve…
kixelated Oct 19, 2026
9e2557b
quest(m1): stale drops name their budget, drop runs bill the pool
kixelated Oct 19, 2026
e87ed60
quest(m1): a stale drop that loses its edge is invalidated
kixelated Oct 19, 2026
468ec98
quest(m1): drop-set cursors settle whole runs, resolved-demand requir…
kixelated Oct 19, 2026
5e3ed35
quest(m1): received stale drops record no edge, drop-set requires sub…
kixelated Oct 19, 2026
99e9eda
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/resolve…
kixelated Oct 19, 2026
09a8642
quest(m1): each cursor keeps a disposition record next to the drop set
kixelated Oct 19, 2026
a7ae6cd
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/resolve…
kixelated Oct 19, 2026
a13e573
quest(m1): a cursor's floor never rises over an unknown hole
kixelated Oct 19, 2026
4632c0a
quest(m1): resolved-demand owns the cursor record, stale reports neve…
kixelated Oct 19, 2026
d03dddb
quest(m1): a stale drop names its edge, so a narrow range is not sett…
kixelated Oct 19, 2026
d7a038a
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/resolve…
kixelated Oct 19, 2026
0d67320
quest(m1): only a widening reverses a stale verdict
kixelated Oct 20, 2026
7e0cac1
quest(m1): defer in-flight stale drops across a range change
kixelated Oct 20, 2026
306f6a8
quest(m1): a drop folded under pressure is final for its cursor
kixelated Oct 20, 2026
57ba875
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/resolve…
kixelated Oct 20, 2026
9ea466d
quest(m1): eviction and expiry drops are budget-independent
kixelated Oct 20, 2026
2885fee
quest(m1): a relay's own eviction is a range fill, never a drop
kixelated Oct 20, 2026
15d7cb6
Merge branch 'main' of github.com:moq-dev/moq into quest-plan/resolve…
kixelated Oct 20, 2026
649134a
quest(m1): JS runs expire with the cache window, no new cap
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
2 changes: 2 additions & 0 deletions quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ 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
- [A widened max age re-offers cached groups](/quest/m1/resolved-demand.md) - a warm relay serves a reader with a wider max age the cached groups a cold relay would, with no wire change
- [Drop set](/quest/m1/drop-set.md) - each cursor judges missing groups against its own budget, so a narrow reader drops promptly and a widening re-offers groups already reported dropped on lite-07
- [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
Expand Down
225 changes: 225 additions & 0 deletions quest/m1/drop-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
# [L] Drop set

## Goal

Every cursor judges a missing group against its own budget. Today a dropped
group is final for every reader of a track: a narrow reader can wait
indefinitely for an upstream OLD naming a group the relay never cached,
and a wider reader never gets a group an upstream already reported stale.
The track state instead keeps the dropped and expired runs with their
reason and budget, so a narrow reader gets a prompt drop, a wide reader
waits, and a widening re-offers what its new budget covers. On lite-07 this
restores "growing means I want it": a group reported dropped under a
narrower budget is served after a widening, as a cold relay would.

## Plan

Decided by the maintainer on 2026-10-19, splitting this from
[A widened max age re-offers cached groups](/quest/m1/resolved-demand.md),
which keeps drops final for now.

- **One run-length set per track.** The track state keeps the dropped and
expired group runs within the subscription range. Each run carries its
reason (stale, cut, never produced, or evicted or expired where no copy
remains: received as such from the upstream, or at an origin with no
upstream) and, for a stale run, the
budget it was judged under. A group that arrives removes its sequence; a
drop never removes a group that already arrived. A relay's own eviction
is never a run: the group is unknown again, and any cursor that wants it,
new, widened, or already owed it, fills it from upstream by range, the
existing miss fill. That needs no new state, since the cursor's record
already holds the span as unknown, a run at a time.
- **Each cursor judges for itself, a run at a time.** A cursor walks the
set's runs in its range, never their sequences, and settles each whole run
in O(1):
- A run whose reason does not depend on the budget (a cut, a group never
produced, an upstream or origin eviction or expiry) is dropped for every reader
as one range, as today.
Comment thread
kixelated marked this conversation as resolved.
- A received stale run is dropped promptly as one range for a reader
whose budget is no wider than the one it was judged under and whose
range covers this relay's aggregate range for the track, which the
upstream judged under. A verdict depends on the range as well as the
budget, since `drifted` caps the edge to the reader's range
(`Edge::cap`), so a narrower reader treats the run as unknown and fills
it by range, where the upstream judges it under that narrower range.
The cost is an extra range request, only for narrower readers, when a
stale drop arrives.
- A missing run this relay judges itself is dropped for a reader under
whose own budget and range `TrackState::drifted` convicts it. `drifted`
judges a missing sequence by its successor here, and every sequence in
a missing run shares one successor, so one call settles the run.
- A wider reader skips the run and waits for a group, a re-offer, or its
own verdict, each of which splits the run at most once.
- An unknown span (a gap between runs, or an evicted run) is judged the
same way, as one unit bounded by its neighbours: by one `drifted` call
against its shared successor, or by one range miss fill upstream.

A hostile upstream's huge run costs one Charge and O(1) per cursor, and
splitting it costs per-run Charges the pool bounds, so CPU tracks pool
bytes, never span.

This fixes the gap Codex found on
[moq-dev/moq#5986](https://github.com/moq-dev/moq/pull/5986): today
`TrackRun::poll_forward_drop` emits only recorded drops, cuts, or stale
cached groups. A sequence the relay never cached is not judged until the
track finishes.
- **It extends resolved-demand's per-cursor disposition record,** which
stays next to the per-track set: a group that arrives leaves the shared
set, so only the cursor's record remembers that it reported cached G
stale. A reported run now carries its reason and, for a stale one, the
budget it was judged under. On lite-07 a reported stale run is no longer
final: like a skipped one, it is re-offered when a widening covers it.
- **A widening re-offers the dropped runs now inside its budget,** through
the re-offer path the resolved-demand quest adds, in the subscription's
order. Upstream, the relay's own widening asks for them again.
- **Only a widening reverses a stale verdict,** as resolved-demand
decides, local or received alike: no run tracks the group that convicted
it, and an abort or eviction of that group leaves the run standing (the
open question in resolved-demand). A received run is also reversed by
the upstream re-offering its group after a widening, since a later group
wins over its drop. Cuts and never-produced runs are unaffected.
- **A stale drop names its budget.** A SUBSCRIBE_UPDATE has no
acknowledgement, so after a widening from B1 to B2, an in-flight B1 drop
and the B2 re-drop look identical, and no guess settles a B2 reader. On
lite-07 SUBSCRIBE_DROP carries `Max Age`: the budget a staleness verdict
was judged under, encoded like `Publisher Max Age` (milliseconds plus one),
Comment thread
kixelated marked this conversation as resolved.
with `0` for a drop that does not depend on the budget: a cut, a group
never produced, or a group the sender evicted or expired with no copy it
could fetch (an origin, or a relay forwarding its upstream's eviction
drop), none of which depends on the reader's budget. A relay never sends
an eviction drop for its own eviction; it fills the group by range
instead. A widening never reverses a `0` drop. The relay records each run with that
exact budget. A B1
drop then settles only readers no wider than B1, and the B2 re-drop
settles a B2 reader. The cost is one varint per drop, a few bytes on an
event that is already per group, which is worth an unambiguous term.
Rejected: an implicit generation counted by both sides per
SUBSCRIBE_UPDATE. It needs a generation-to-budget history at the relay
and agreement on every update counted, while the budget itself is
self-describing, order-free, and unaffected by updates that change
nothing.
- **lite-07 only for re-offering reported groups.** Released lite-05 and
lite-06 peers treat a flushed SUBSCRIBE_DROP as final, so a drop received
over those versions is final for every reader, and a lite-07 hop never
sends a group onto a lite-05 or lite-06 hop after reporting it. On
lite-07, a widening obliges the publisher to serve the newly covered groups
it holds and never delivered on this subscription, including ones it
reported dropped, and to drop again, under the new terms, the ones it
cannot serve. SUBSCRIBE_UPDATE stays incremental and never resets, since a
reset races in-flight, out-of-order data. A shrink sends no drops.
- **Draft wording.** `drafts/draft-lcurley-moq-lite.md` states, for lite-07's
restored SUBSCRIBE_DROP, that a drop holds only under the terms in effect
when it was sent, that a widening re-offers held groups and re-drops the
rest, and that a later group, when sent, wins over its drop. Whichever of this and
[SUBSCRIBE_DROP accounts for every group](/quest/m1/subscribe-drop.md)
lands second writes it, including the `Max Age` field above, which is
free to add because lite-07 is unpublished.

Code it replaces, in Rust and `js/net` alike:

- `TrackState::drop_groups`, the `drops` range set and `DropCursor` in
`rs/moq-net/src/model/track.rs`, which record a whole range without its
reason or budget, even over groups already cached.
- In `js/net/src/track.ts`: `#drop`, `#undrop`, the `drops` signal, and
the wire cursor's `#pending` and `#dropsSeen`, whose `#tryRecvGroup`
closes any group already reported.

Untrusted input: a hostile upstream can fragment the set by alternating
drops and arrivals, and an open live range is no bound, since its groups are
evicted while their runs would stay. Bill each run to the cache's existing
byte accounting like a group: the Rust `Pool` through a `Charge` at a fixed
per-run cost. Under pressure the pool evicts the oldest runs as it evicts
groups, and a run older than the cache's retention window is forgotten, so
the set never outgrows what the pool would hold, with no new constant. An
evicted run's sequences become unknown again. `js/net` has no byte pool and
gets no new cap: its shared and per-cursor runs expire with the same idle
window as the groups they sit between, and expiry applies the same
fold-or-`TooFarBehind` rule as a Rust eviction. A run sits between cached
sequences, so there are at most about one per cached sequence (alternating
drop and arrival), and JS run state grows no faster than the JS cache
itself. The JS byte bound for both is
[JS unbounded groups](/quest/m2/unbounded-groups/js.md).

The per-cursor record keeps resolved-demand's pool bound and floor rule,
with "final" narrowed: only a delivered run or a budget-independent drop (a
cut, a group never produced, an upstream or origin eviction or expiry, or any drop
reported over lite-05 or lite-06)
folds into the floor. A stale run reported on lite-07 within reach of the
Comment thread
kixelated marked this conversation as resolved.
max age bound is budget-qualified, so it is treated like an unknown hole:
the floor never rises over it, and if the pool must evict it or a run above
it, the cursor fails with `TooFarBehind`. Folding it would forgo the
re-offer this plan promises after a widening. A later group wins over its
drop only while the cursor's record still holds that drop: a drop folded
into the floor under pool pressure is final for that cursor, so its group
arriving late is not delivered there.

A cursor judges an unknown missing sequence (never a stale run its record
still holds as reported, which only a widening settles) as it would a
group never received: locally against its own budget (`TrackState::drifted`),
and otherwise as a cache miss, which the relay fills upstream by range
([Subscribe ranges](/quest/m1/subscribe-ranges/README.md)), so no reader
waits indefinitely on forgotten state. Extend `track_subscriber_churn` in
`rs/moq-net/benches/track.rs` with a fragmented set up to pool capacity,
swept over subscriber count, shared runs, runs per cursor record, and run
span, so enumeration cost tracks the touched runs, never the set, the
record, or a run's span.

Test on mock time through a relay:
- On a live track, a reader no wider than the upstream budget gets a prompt
drop for an upstream OLD naming a group the relay never cached.
- On lite-07, a widened reader gets that group re-offered. A drop already
flushed before the widening SUBSCRIBE_UPDATE was received, so the reader
then gets the re-offer and no further final drop; a drop still in flight
does not settle the widened reader. Check what each reader receives, not
only tail accounting.
- On lite-07, after a widening from B1 to B2 for a group the upstream no
longer holds, an in-flight drop tagged B1 settles only the B1 reader, and
the re-drop tagged B2 settles the B2 reader.
- A group arriving after its drop, while the cursor's record still holds
the drop, is served, and a drop arriving after its group leaves it
served.
- Through a lite-06 hop, a reported group stays dropped for every reader,
with no session error.
- A shrink sends no drops.
- Rust and `js/net` each encode and decode an origin's eviction drop and
retention-expiry drop with `Max Age` `0`, across languages; a relay
forwards them unchanged, and a widening re-offers neither.
- A group a relay evicted while the upstream still holds it is never
reported dropped: a new reader, a widened reader, and a reader already
owed it each fill it by range and receive it.
- A reader whose range is narrower than the relay's aggregate range is not
settled by a received stale drop: it fills G by range and gets G or a
drop judged under its own range, while a reader covering the aggregate
range is settled at once.
- On lite-07, a cursor that delivered F, reported its neighbour G stale
while G stayed cached, and delivered H, gets only G after a widening that
covers all three: G is re-offered and F and H are never resent.
- Under pool pressure, a cursor's delivered runs and budget-independent
drops fold into the floor, while evicting a lite-07 stale-reported run
within reach, or a run above it, ends the cursor with `TooFarBehind`
instead of losing the re-offer. Through a lite-06 hop the reported run
Comment thread
kixelated marked this conversation as resolved.
folds.
- A flood of fragmented runs stays within the pool in Rust, evicting the
oldest shared runs first, and a sequence whose shared run was evicted is
judged locally or filled by range, never waited on indefinitely. In
`js/net` the same flood holds at most one run more than its cached
sequences, and the runs expire with the idle window.
- A single run spanning billions of sequences is settled by each cursor in
one step, whether dropped, skipped by a wider reader, or evicted and
judged again.

Mirror each case in `js/net`.

Public API: none. Wire: lite-07 SUBSCRIBE_DROP (unpublished) gains `Max Age`
and the widening and ordering rules above; published versions are
unchanged.

## Required

- [A widened max age re-offers cached groups](/quest/m1/resolved-demand.md) - the re-offer path this extends to reported groups
- [SUBSCRIBE_DROP accounts for every group](/quest/m1/subscribe-drop.md) - restores the lite-07 drop whose wire and behavior this extends

## Related

- [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - fills the cache miss an evicted run leaves
- [JS unbounded groups](/quest/m2/unbounded-groups/js.md) - the JS byte bound for groups and the runs between them; not required, since JS runs never outgrow the JS cache
Loading