feat: slide the media timeline over the publisher's cache - #2787
feat: slide the media timeline over the publisher's cache#2787kixelated wants to merge 1 commit into
Conversation
The timeline was an append-only `moq_json::stream`, so it listed every segment ever published, including long-evicted ones. An HLS playlist built from it advertised segments that could no longer be fetched. Two root causes. The track could not shrink: a stream's whole log rides one group that is never rolled, and a consumer always starts at frame 0, so evicting the earliest frames bricked the track for late joiners (`Error::Lagged`). `moq_json::window` is a new mode where each group is self-contained (snapshot at frame 0, then append/trim operations) and rolls once the trimmed records outnumber the retained ones. Second, `latency_max` was a knife edge: a group died the instant its age exceeded it, which is the same instant anything reasoning about the window would conclude it was gone. A consumer deciding to fetch there has its FETCH arrive a round trip later, against media already evicted. Retracting *on* eviction cannot fix this, since an observed eviction is after the fact. So `latency_max` becomes a floor: eviction fires a grace period later, and the timeline trims at `latency_max` exactly, leading the eviction it predicts. A record's deadline is the earliest `reported + latency_max` across the groups it names, so tracks with different retention need no rule. The trim sweeps as segments are recorded, which is when moq-net evicts, so the two stay in phase without a timer. `timeline::Producer::track` takes the track rather than its name, so the retention is read from where the track declares it and there is nothing for a caller to duplicate and get wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 98e3706dfb
ℹ️ 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".
| /// segment therefore still finds the groups when its FETCH lands. | ||
| fn sweep(&mut self) { | ||
| let now = Instant::now(); | ||
| let expired = self.expiry.iter().take_while(|&&deadline| deadline <= now).count(); |
There was a problem hiding this comment.
Trim through expired records with nonmonotonic deadlines
When a short-retention track is enrolled after the timeline already contains records from a long-retention track, a newer record can have an earlier deadline than the record at the front of this deque. This prefix-only take_while then stops at the live older record and leaves the newer expired record advertised even after its media has been evicted. The mirrored JS loop has the same problem. Since the wire window can only trim from the head, sweep through the furthest expired record, even if that conservatively removes older records that remain fetchable. (Written by GPT-5.6 Sol)
Useful? React with 👍 / 👎.
| while state.rows.front().is_some_and(|row| row.segment < segment) { | ||
| state.rows.pop_front(); |
There was a problem hiding this comment.
Preserve the media sequence when trimming the last row
If a timeline trim removes every currently listed row without immediately appending another segment, this loop leaves rows empty and State::window falls back to sequence 0. The rendered playlist consequently rewinds EXT-X-MEDIA-SEQUENCE from its previous value to 0, despite the nearby invariant that this sequence must never move backwards. Retain the trim boundary as a sequence watermark so an empty live window still reports the next unavailable segment number. (Written by GPT-5.6 Sol)
Useful? React with 👍 / 👎.
| #prune(): void { | ||
| const latencyMaxMs = this.#state.info.peek()?.latencyMax ?? DEFAULT_LATENCY_MAX_MS; | ||
| const cutoff = Date.now() - latencyMaxMs; | ||
| const cutoff = Date.now() - (latencyMaxMs + EVICT_GRACE_MS); |
There was a problem hiding this comment.
Document latencyMax as a guaranteed floor
This changes JS eviction to occur at latencyMax + EVICT_GRACE_MS, but the exported Info.latencyMax documentation still calls the value the maximum age before eviction. Consumers reading the generated API docs can therefore incorrectly treat it as a hard upper bound when sizing caches or reasoning about retention, while the Rust API and updated draft describe it as a guaranteed floor with unspecified extra retention. Update the public member documentation with the new semantics. (Written by GPT-5.6 Sol)
AGENTS.md reference: AGENTS.md:L86-L88
Useful? React with 👍 / 👎.
Summary
The media timeline was an append-only
moq_json::stream, so it listed every segment ever published, including long-evicted ones. An HLS playlist built from it advertised segments that could no longer be fetched, and moq-hls papered over the misses withEXT-X-DISCONTINUITY.This makes the timeline a sliding window over what the publisher can still serve, retracting a record once the groups behind it reach their track's
latency_max.Root cause
Two distinct bugs, and the second is the interesting one.
The track could not shrink. A
moq_json::stream's whole log rides one group that is never rolled, and a consumer always starts at frame 0. Once moq-net evicted the earliest frames the track was bricked for late joiners (Error::Lagged), and with compression the retained suffix is undecodable anyway since its DEFLATE window depends on the evicted prefix. There was no shape in which a record could be dropped, somoq_json::windowis a new mode: each group is self-contained (snapshot at frame 0, then{"append":V}/{"trim":N}operations) and rolls once the records trimmed since the snapshot outnumber those still in the window. Total bytes stay at roughly 2x an append-only log, old groups become disposable, and a late joiner reads only the newest.latency_maxwas a knife edge.evict_expireddropped a group the instant its age exceededlatency_max, which is the same instant anything reasoning about the window would conclude the group was gone. A consumer that decides to fetch at that edge has its FETCH arrive a round trip later, by which time the media is already evicted. This is not fixable by retracting on eviction: an observed eviction is by definition after the fact, so the retraction is emitted when the content is already gone and then takes another propagation delay to arrive. The timeline has to lead the cache, which only a predictive trigger can do.So
latency_maxbecomes a floor rather than a deadline: age eviction now fires atlatency_max + EVICT_GRACE(1s), and the timeline trims atlatency_maxexactly. The user-facing knob keeps its meaning (30s configured is 30s of timeline and 30s guaranteed fetchable) and the grace is invisible slack. It fixes the race for every reader of the window edge, not just the timeline.Design notes for reviewers
timeline::Producer::tracktakes thetrack::Producer, not its name. The retention is read from the same place the track declares it, so there is nothing for a caller to duplicate and get wrong. This is deliberate: an earlier attempt at a JS retention timer (bafc419a4, on the feat: trim the media timeline as segments leave the publisher's cache #2631 branch) was reverted precisely because it guessedDEFAULT_LATENCY_MAX_MSand silently mismatched any track published with a differentlatencyMax. That is an argument against a guessed constant, not against time-based trimming.reported + latency_maxacross the groups it names. A segment is fetched whole, so the first group to leave its cache breaks it as thoroughly as any other; a broadcast whose audio keeps 5s and video keeps 60s falls out of that without a special case.commit_grouprunsevict_expired. The two stay in phase, and a publisher that stalls stops trimming and stops evicting together.web_async::time::Instant, which is what moq-net's cache pool already uses.std::time::Instantwould drift from it under wasm and under a paused test clock. It is wall-clock rather than pts on purpose: eviction is wall-clock, so a file import racing pts ahead of real time must not trim content the cache is still holding in full.Fanout::trimleaves the wall-clock anchor alone, even when a retraction empties the replay history. The anchor maps the pts axis onto wall clock and a retraction says nothing about that axis; re-estimating would jumpEXT-X-PROGRAM-DATE-TIMEand DASHavailabilityStartTimeover content that never moved, and would do it exactly when the publisher is shedding media, which is when a fresh "this segment just ended" estimate is least true.Relationship to #2631
Same diagnosis of the append-only problem and the same
moq_json::windowformat, which is lifted from that branch unchanged. The trigger is what differs: #2631 retracts by observing eviction, via a newgroup::Demandprimitive in moq-net andGroup.Consumer.gone/Producer.evict/Track.Subscriber.tryNextGroupin js/net. That is exact but structurally too late, as above. Predicting from the declared window is both smaller and correctly ordered, so this dropsgroup::Demandand thegone/evictsurface entirely.tryNextGroupstays, since the window primitive itself needs it to reach the newest buffered group.The tradeoff taken knowingly: the prediction is optimistic where observation was exact. A group dropped early by the cache pool's byte budget is still listed until its declared window elapses. That is a bounded error on a cache hint, it self-corrects at the next group's snapshot, and moq-hls keeps the discontinuity path as the genuine-miss fallback.
Public API changes
Added
rs/moq-net:track::Producer::info(), the effective (post-clamp) track info.rs/moq-json: thewindowmodule (Producer,Consumer,Update,ProducerConfig,ConsumerConfig), plus anError::Windowvariant (the enum is#[non_exhaustive]).rs/moq-mux:timeline::Update.js/json: theWindownamespace, mirroring the above.js/net:Track.Producer.accepted(the synchronous peek atinfo()) andTrack.Subscriber.tryNextGroup().Changed
rs/moq-mux:timeline::Consumer::{next, poll_next}yieldUpdate<E>rather thanEntry<E>, since an entry alone cannot express a retraction.Entryis unchanged and rides insideUpdate::Append.timeline::Producer::trackandcatalog::Producer::enrolltake&moq_net::track::Producerrather than&str.js/hang:Container.Timeline.Producer.tracktakes theMoq.Track.Producer, matching.Behavior
moq_net::track::Info::latency_maxis now the age a group is guaranteed to survive; eviction fires a short unspecified grace later. Mirrored injs/net'sTrack.Producercache prune.Wire format (breaking). The timeline track changes from an append-only stream to a sliding window. Nothing negotiates this: a new consumer against an old publisher errors rather than mis-parsing. That is acceptable here specifically because the catalog's
timelinefield is still being specified, so breaking it now costs nothing and breaking it later would cost a version.Cross-Package Sync
rs/hangcatalog/container ->js/hang,drafts/: done.draft-lcurley-moq-hang's Timeline section specifies the window framing (snapshot,append/trim, group rolling) and a new Availability section covering retraction.rs/moq-netwire/API ->js/net,drafts/: done.draft-lcurley-moq-lite's Expiration section now statesPublisher Max Latencyas a floor with the round-trip margin, with a changelog bullet under moq-lite-06. The hang draft cites it rather than restating.doc/: no change. The timeline is not documented on the site and no CLI surface moved.Test plan
just checkpasses, includingcargo docwith-D warnings.just drafts check(kramdown-rfc) passes on all 8 drafts. TheHT characterswarning on the hang draft is pre-existing: its schema blocks use tabs.latency_maxdeadline and dying past the grace; a record retracted once its groups expire; a live consumer seeing the retraction with the oldest surviving segment named; the shortest retention bounding a multi-track record; and the load-bearing one,the_retraction_leads_the_eviction, which asserts the record is gone while a real FETCH on its group still succeeds. That test was mutation-checked by zeroingEVICT_GRACE, and fails without it. Plus moq-hls trims dropping segments the duration window would keep, never rewindingEXT-X-MEDIA-SEQUENCE, and emptying a window when trimmed past.Branch targeting
Targets
dev:timeline::Consumer::next,timeline::Producer::track, andcatalog::Producer::enrollall change signature, as doesjs/hang'sTimeline.Producer.track.(Written by Opus 5)