Skip to content

feat(moq-net): time anchors for subscription ranges - #6074

Merged
kixelated merged 68 commits into
mainfrom
quest/m1/subscribe-ranges/time-anchors
Oct 21, 2026
Merged

kixelated merged 68 commits into
mainfrom
quest/m1/subscribe-ranges/time-anchors

Conversation

@kixelated

@kixelated kixelated commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

A range start or end may now name a presentation time, track::Anchor::Time(pts), for DVR seek. A start resolves to the group containing pts. An end resolves to just past that group. An untimed track refuses either.

This picks up the time anchors split out of #5929 and re-ports them onto main's Servable window, next_in_range, and resumed-reader resolution. The design fixes the three open findings from that review.

Design

An unsettled time end keeps forward-only pointers in Window:

  • known: the newest group known to start at or before pts. It advances through a track-level index of frame-0 starts, so it passes stalled groups.
  • held: a walk over held groups. It stops at the first group that may still start (the one speculative group) or at a group starting past pts.
  • accounted: the settle boundary. It walks held groups, drops, and ledger runs up to the first group starting past pts. When it reaches that group, the end settles as Before(it).

Per-read rescan (finding 1). Each pointer only moves forward, so a peer that leaves an early sequence missing and keeps sending makes each read scan only what arrived since. The regression test counts holdings queries behind a 2000-group gap: about 2M queries without the fix, linear with it.

Failover (finding 2). Window::resolved reports an unsettled time end as unresolved, not as its cap. Subscription::clamped keeps an unresolved Time end instead of folding a copy's failover cap into it. The copy cursor keeps the time filter, and the reader's until keeps the cap.

Speculative group (finding 3). At the live edge, reads stop at the first held group that may still start. So at most one group past the end is read before its first frame shows where it starts. A reader parked on that cap wakes on the group's poll_started, and on a Wakes::watch_start that the frame-0 hook fires when a later group starts. A held group that never shows a start (empty, aborted, or never-start) does not pin the cap. Datagrams never go through the settle walk. Each datagram is judged on its own: its timestamp against the range's time start and end, and its sequence against the range's absolute groups and the relative ones resolved so far. Datagrams stay ephemeral: a buffered datagram never records where its group starts. So a time end settles across a sequence that arrived only as a datagram, and a stream copy of that group arriving later is read even if it starts past the end. That edge is accepted and documented at the settle walk. Refusing time anchors on datagram tracks was considered and can't work. Nothing signals that a track carries datagrams, and the lite draft lets a publisher send any single-frame group as a datagram, including a copy of a group it also streams.

Decisions

  • Datagrams and the starts index (maintainer, 2026-10-20: "datagrams stay ephemeral"):
    • ✅ Revert the buffered-datagram start indexing and accept the stream-copy-after-datagram edge. This resolves both Codex threads on ordering and duplicate eviction by removing the bookkeeping.
    • Keep the indexing and add an ordering guard on stream starts plus a duplicate-aware eviction.
  • Per-datagram judging from 771c1558ca (agent, recommended):
    • ✅ Keep it. Judging each datagram by its own timestamp and its absolute or resolved relative groups is the earlier 2026-10-20 maintainer decision and does not depend on the index.
    • Revert it with the index.

Public API

Breaking, with MIGRATE.md entries in every package:

  • moq-net:
    • track::Anchor gains Time(Timestamp).
    • Subscribing, or Control::update, with a time anchor on an untimed track fails with Error::TimestampMismatch.
  • moq-ffi:
    • MoqAnchor gains Time { timestamp_us }.
    • MoqTrackConsumer::update now returns Result<(), MoqError>.
    • Converting a subscription can fail with TimeOverflow.
  • moq-c:
    • moq_anchor gains timestamp_us, read with the new MOQ_ANCHOR_TIME kind. This is an ABI change.
    • moq_consume_track_update returns a negative code when the track refuses the update, and leaves the subscription unchanged.
    • The raw consumer now applies updates directly to the live Control instead of going through a channel.
  • Wrappers:
    • Go: AnchorTime.
    • Kotlin: AnchorTime.
    • Swift: .time(timestampUs:).
    • Python and Dart: the new variant.
    • update now throws, raises, or returns an error on a refused update.
    • C++ gets it through its generated bindings.

Wire

None. No wire carries anchors yet. The lite-07 encoding, including Time, lands with the lite-07 ranges quest, and so does its draft text.

Deferred

  • Passing an unresolved time anchor to a track::Dynamic handler. This waits for dynamic range requests, and its quest now says so.
  • The @moq/net mirror. The JS ranges quest, quest/m1/subscribe-ranges/js.md, already includes Time.

Tests

  • Regression tests, each verified to fail without its fix:
    • a_time_end_walks_each_group_once_behind_a_missing_sequence
    • capping_a_replaced_copy_keeps_its_time_end
    • a_time_end_reads_one_group_past_it_at_the_live_edge
    • a_read_parked_on_a_time_end_wakes_as_groups_start
    • a_datagram_is_judged_against_a_time_end_by_its_timestamp
    • a_datagram_is_judged_against_a_time_start_by_its_timestamp
    • a_settled_window_still_judges_a_datagram_by_its_times
    • a_waiting_window_judges_a_datagram_against_absolute_groups_too
    • a_time_end_settles_on_a_group_that_starts_between_its_walks
    • an_untimed_track_refuses_time_anchors
  • Bench: track_time_end sweeps 1, 8, and 64 readers over 64, 512, and 4096 groups behind a missing sequence. Throughput stays roughly flat as groups grow:
    • 1 reader: about 675K to 527K elem/s
    • 8 readers: about 1.07M to 955K elem/s
    • 64 readers: about 1.17M to 893K elem/s

The starts index is charged in track::CACHE_OVERHEAD. A track whose group starts rewind is unsupported (#5446). Its cost stays bounded, but its own filtering may be wrong.

Also fixes the session bench id, which #6028 left with one argument more than its format string, so the bench failed to compile on main.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 30 commits October 18, 2026 22:12
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A subscriber now reads only the groups its own Subscription::ranges cover,
resolved where it is served and again whenever they are replaced, instead of
reading every group the aggregate brings in. A new Anchor::Time(pts) resolves
from the cached groups' starts; an untimed track refuses it.

The drop scan names only sequences inside the reader's ranges, so the
SUBSCRIBE_DROP accounting can reuse it. moq-c and moq-mux stop re-resolving
relative starts on their own. moq-ffi, moq-c, and the bindings carry the time
anchor, and the FFI update now reports a refused one.

Adds track_range_serving, which found and fixed two per-range walks per
hand-out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The resolved window and its bookkeeping live behind one box so every reader
that embeds a cursor stays its old size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also document the extra group a time end may let through at the live edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moq_consume_track_update queued updates for the reader task, which dropped a
TimestampMismatch, so a time anchor on an untimed track silently kept the old
ranges. Once subscribed, updates now go through the track's Control and fail
at the call; one made before SUBSCRIBE_OK applies once it arrives and a
refusal then ends the consumer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…atagrams too

A copy joined mid-group stamps a later frame than the group's start, so it
could convince a time end that the group started past it. A refused cursor
also read and discarded datagrams instead of returning its refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…guide

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…vers

The arrival walk passed groups below the floor without parking them, so
widening the ranges (or a SUBSCRIBE_UPDATE lowering Group Start) could never
serve a cached group that arrived before the ones already read. A relay could
not fold a second subscription into one by lowering the start.

The cursor now remembers the highest floor its walk applied. When the floor
drops below it, the cached groups in between are looked up by sequence and
parked, so each update costs only the groups it re-offers, and the subscriber's
delivered set keeps any from going out twice. This reverses #4940's no-rewind
rule; its two publisher tests now pin the new contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p cap

An unresolved time end covers every group handed out before its first frame,
not just one. Say so on Anchor::Time and in the concept doc, and track capping
it at one speculative group in quest/m1/subscribe-ranges/time-end-cap.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local track::Subscriber hands out whole groups even when its range starts or
ends mid-group. That predates this branch (reproduced on main 824179a), and
the wire publishers already position each group per subscription, so it is
tracked in quest/m1/subscribe-ranges/frame-bounds.md. The drift_cap comment
still said ranges don't filter the subscriber; say why the edge stays uncapped
by them instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backfill and has_unread_group read the cache by sequence, so they also
picked up fetched backfill (never replayed in arrival order) and groups a route
still withholds until reveal_group. Filter both to shown slots, as the arrival
walk does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ngelog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A replacement copy clamped every relative or time start to the reader's
newest delivered group, so a later disjoint time range (Time(10s), say) was
asked from group 0 and its own filter let through everything in between.
Capping a replaced copy did the same to a time end.

The Window now reports each range resolved, by index. A replacement copy is
clamped from where the serving copy resolved the reader's ranges, read before
that copy is capped, and a copy recomputing its own wire uses its own
resolution, or resolves replaced ranges against its cache now. Only a range
not resolved yet still becomes the bound itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	dart/moq/MIGRATE.md
#	go/wrapper/MIGRATE.md
#	kt/moq/MIGRATE.md
#	py/moq-rs/MIGRATE.md
#	rs/moq-c/MIGRATE.md
#	rs/moq-ffi/MIGRATE.md
#	swift/MIGRATE.md
A relative or time start a replacement copy cannot resolve yet, such as one
updated while the copy is still pending, keeps its anchor instead of
becoming the floor, which would read every group from there. A pending copy
replaced in turn passes on the resolution it was handed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot to

A start the wire kept as an anchor can resolve below the copy's floor once
it answers, and the reader never delivered the groups it skipped there, so
the delivered set did not stop them. Raise the copy's local start to its
floor when it answers, as start_at already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntroduced

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	cpp/moq/MIGRATE.md
#	dart/moq/MIGRATE.md
#	go/wrapper/MIGRATE.md
#	kt/moq/MIGRATE.md
#	py/moq-rs/MIGRATE.md
#	rs/moq-ffi/MIGRATE.md
#	rs/moq-net/src/model/resume.rs
#	swift/MIGRATE.md
…e cache

A reader no longer parks a consumer and an abort watch for every group
outside its ranges or past its cap, which a narrow reader under an open
aggregate accumulated per produced group. The walk passes such a group
holding nothing. A move of the floor, ranges, or cap owes the spans it newly
admits, and the walk re-offers what the cache still holds there through an
ordered lookup, once per widening: O(log n + k) for the k groups in the span.
This subsumes the lowered-floor backfill. A finished track now ends a reader
with nothing left in range instead of holding it open for a cap that may rise.

Drift is measured under where the ranges end as well as the local cap, so a
range behind the live edge keeps its own newest group.

track_range_widening replaces track_parked_abort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ef73d3f9d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-net/src/model/track.rs
A datagram filling a gap counted as the sequence having arrived, so a
time end settled across it without knowing where that group starts, and
the same group streaming later was read past the end. A datagram is its
group's frame 0, so the starts index now holds each buffered datagram's
timestamp, dropped when the datagram leaves the buffer unless the group
is cached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Claude Opus 5.5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ea298982d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-net/src/model/track.rs Outdated
Comment thread rs/moq-net/src/model/subscription.rs
…scending

A datagram is judged on its own: its timestamp against a range's times
and its sequence against its absolute and resolved relative groups, never
against where a time end settled. A datagram that would rewind the starts
index is left out of it, so the index stays ascending for the groups a
time end settles against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Ready for maintainer review as it stands at 771c155. I've stopped iterating on Codex.

After the per-datagram decision, four Codex rounds found only edge cases. Each one is fixed with a regression test that fails without it:

  • a time end spinning at u64::MAX
  • a missing sequence leaking groups past an end
  • a datagram in a gap settling an end blind
  • a rewinding datagram breaking the starts order

The JS mirror and the binding-guide docs were declined, with reasons in their threads. All 9133 workspace tests and just test interop --all pass. redundant_importers_switch_through_bufferbloat_on_lite_07 flakes, which is known on main.

Known residual: a stream copy of a group that arrives after its datagram has left the 64-entry send buffer, before any reader settled the end, is placed only by its arrival. Fixing that would need datagram starts kept past the buffer.

(Written by Claude Opus 5.5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 771c1558ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-net/src/model/track.rs
Comment thread rs/moq-net/src/model/track.rs Outdated
@kixelated

Copy link
Copy Markdown
Collaborator Author

Parked for maintainer review at 771c155. No more pushes, review requests, or main merges from this session.

Open:

  • JS mirror: deferred to the JS ranges quest (quest/m1/subscribe-ranges/js.md). js/net has no ranges yet.
  • Unresolved: stream start indexed after a later datagram (thread). With stream 0@0, datagram 2@150, then stream 1@200, the stream start is indexed without the ordering guard datagrams get. That leaves starts out of order, so a time end can settle before group 1.
  • Unresolved: duplicate datagram eviction (thread). Evicting one of two buffered datagrams with the same sequence drops their shared starts entry, because only the group cache is checked, not holds_datagram.

Both unresolved findings come from indexing buffered datagrams' starts, added in 3ea2989 and 771c155. If datagrams are treated as fully ephemeral, reverting that indexing removes both. It also brings back the earlier stream-copy-after-datagram finding.

(Written by Claude Opus 5.5)

kixelated and others added 3 commits October 20, 2026 13:04
A buffered datagram no longer records where its group starts. Indexing
them kept a time end from settling across a sequence that arrived only as
a datagram, but it needed eviction, duplicate, and ordering bookkeeping
against the stream starts, each with its own edge cases. The edge it
covered, a stream copy of a group already sent as a datagram arriving
after the end settled, is accepted and documented at the settle walk.
Datagrams are still judged per datagram by their own timestamps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
recv_datagram exists only with the async feature, so the no-default
features build failed to compile the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Claude Opus 5.5)

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: f108fa9034

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary for f108fa9034:

  • Datagrams stay ephemeral (maintainer): 682b22481c reverts the buffered-datagram start indexing from 3ea298982d and 771c1558ca. A time end now settles across a sequence that arrived only as a datagram, so a later stream copy of that group may be read past the end. That limitation is documented at the settle walk in window.rs and in the subscribe-ranges quest. This resolves both Codex threads on starts ordering and duplicate eviction.
  • Per-datagram judging stays (maintainer): each datagram is still judged by its own timestamp against the range's times, and by its sequence against absolute and resolved relative groups.
  • f108fa9034 gates a_datagram_is_judged_against_a_time_start_by_its_timestamp on async, fixing the net-features build.
  • Merged main. Local just check and quest check pass. A Codex review of f108fa9034 found no issues.
  • The JS mirror stays deferred to quest/m1/subscribe-ranges/js.md.

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 20, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 20, 2026
@kixelated

Copy link
Copy Markdown
Collaborator Author

Merging: clean main merge (eecb86c) on the Codex-reviewed head f108fa90, so that review still covers it. Datagrams stay ephemeral, as decided. The overlapping moq-net changes from #6003 and #6058 do not conflict and compile and pass together. Local just check (7834 + 1115 Rust tests, bindings) and just test interop --all pass.

(Written by Claude Opus 5.5)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant