Skip to content

feat(moq-net): shared RAM LRU group cache (5s/unbounded default) + FFI Cache surface#1899

Open
kixelated wants to merge 7 commits into
devfrom
claude/moq-net-cache-lru-dev
Open

feat(moq-net): shared RAM LRU group cache (5s/unbounded default) + FFI Cache surface#1899
kixelated wants to merge 7 commits into
devfrom
claude/moq-net-cache-lru-dev

Conversation

@kixelated

@kixelated kixelated commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a single shared RAM LRU cache for groups, configurable at the origin, broadcast, or track level (cascading: track over broadcast over origin). A Cache handle is cheaply cloneable and Arc-backed: everything attached with the same handle draws from one max_bytes budget and one max_age; two distinct Cache instances are independent.

Rebased on #1900's ownership model. dev's fa5406a (broadcasts own tracks own groups own frames via inherited Arc<Info>) is the baseline; this PR's Cache layers on top. Concretely: tracks are minted only via BroadcastProducer::create_track (TrackProducer::new is now pub(crate) + takes the parent Arc<BroadcastInfo>), groups carry GroupInfo + the inherited TrackInfo, and the default per-broadcast Cache is installed on the BroadcastProducer and cascades to its tracks through create_track. dev's older TrackInfo::cache: Duration + evict_expired retention is removed (this PR replaces it).

  • Default = 5s / unbounded, per broadcast (backwards compatible). Every BroadcastProducer installs its own default Cache with max_age = DEFAULT_CACHE (5 seconds) and no byte cap, cascaded onto its tracks. A late subscriber can replay the last few seconds, matching the historical behavior. Each broadcast gets its own instance, not a shared global. An explicit with_cache at any level overrides it and can share one budget across many.
  • LRU by wall-clock last-access. Eviction key is the wall-clock instant a group was last accessed (read/served), not media timestamps or arrival time. Evicted once it exceeds max_age since last access, or the shared total exceeds max_bytes (least-recently-accessed first). Uses web_async::time::Instant.
  • Knobs. max_bytes == 0 means no byte cap (eviction by age alone); max_age == Duration::ZERO disables retention (latest group only); the current max_sequence group is never handed to the cache, so it's never evicted out from under a live subscriber.

Public API additions

moq-net

  • cache::Config#[non_exhaustive], Default ({ max_age: DEFAULT_CACHE (5s), max_bytes: 0 }), with_max_bytes / with_max_age; fields max_bytes: u64, max_age: Duration.
  • Cache (re-exported flat) — #[non_exhaustive], Cache::new(Config), Cache::is_clone; cheaply cloneable. pub(crate) Cache::max_age().
  • TrackProducer::with_cache(self, Cache) -> Self, BroadcastProducer::with_cache, OriginProducer::with_cache (override the default).
  • GroupProducer::cached_size(&self) -> u64, GroupProducer::is_aborted(&self) -> bool.

moq-ffi / libmoq (Cache builder surface)

  • MoqCache (Object) + MoqCacheConfig (Record: max_bytes, max_age_ms); MoqCache::new, clone_handle, is_clone. MoqCacheConfig defaults to 5000 ms / 0 bytes.
  • MoqBroadcastProducer::with_cache(&MoqCache), MoqOriginProducer::with_cache(&MoqCache) (builder, mirroring moq-net). Bare MoqBroadcastProducer::new() inherits moq-net's 5s default (no bespoke FFI cache); pass one MoqCache to several broadcasts to share a budget or cap RAM.
  • libmoq C: moq_cache_create(max_bytes, max_age_ms), moq_cache_close(cache), moq_publish_with_cache(broadcast, cache); moq_publish_create inherits the 5s default. moq.h regenerates via cbindgen (no signature change).

Breaking changes → correctly targets dev

  1. MoqTrackInfo.cache_ms removed from the FFI surface (and cache_ms/cache from every binding); retention is now a Cache policy.
  2. TrackInfo::cache: Duration removed from rs/moq-net (and with_cache(Duration) + its serde + evict_expired). This field was local-only — never carried on either wire protocol (verified: neither the moq-lite TrackInfo codec nor moq-transport frames a cache field; dev reconstructs it locally from DEFAULT_CACHE). So this is an API/local-policy change, not a wire-format change. DEFAULT_CACHE (5s) stays as the canonical default retention window, now the default cache::Config::max_age.

The default retention behavior is unchanged vs. dev (still ~5s of history), so this is not a behavior regression for existing publish→subscribe flows; it's an API reshape (config-driven Cache instead of a TrackInfo field).

Supersedes #1898 (built against main).

clamp_stale semantics (rebased on the Cache)

clamp_stale no longer reads the removed TrackInfo::cache; it derives from the attached Cache: stale.min(cache.max_age()) — a subscriber can't wait for a late group longer than the cache keeps it. A zero-age (latest-only) cache, or no cache at all, clamps to Duration::ZERO. Duration::ZERO is left untouched by the min.

Cross-package sync

  • js/net: already implements the 5s window (DEFAULT_CACHE_MS = 5000, age-based prune, no byte cap) — matches the Rust default; dropped the local-only cache field from TrackInfo (also never on its wire codec). bun test + tsc -b + biome clean.
  • doc/concept: layer/moq-lite.md retention prose describes the 5s-per-broadcast default and the explicit-cache knobs.
  • py/swift/kt/go wrappers: expose the Cache surface + with_cache, drop cache_ms, and document the 5s/no-byte-cap default. Go gains DefaultCacheConfig() since a zero-valued CacheConfig would otherwise mean a zero window (latest-only) rather than the 5s default. doc/lib/{py,swift,kt,go,c} reference neither cache_ms nor a TRACK_INFO cache field, so they need no edits.

Folded-in fixes (from #1898 CI + review)

  1. rustdoc intra-doc links fully qualified; RUSTDOCFLAGS="-D warnings" cargo doc passes.
  2. #[non_exhaustive] on Cache (not just Config).
  3. touch-before-evict: read paths run eviction first, then refresh+serve only if still valid; a group past max_age is evicted on read, not revived. All read paths.
  4. read_frame bumps LRU recency like recv_group/get_group.
  5. dynamic byte accounting: the byte-eviction pass re-queries cached_size() so a superseded group that grows via late frames is counted at its grown size.

Test plan (re-run post-merge with #1900)

  • cargo test -p moq-net --lib — 417 passed (incl. default_retains_recent_groups, default_evicts_after_window, zero_age_keeps_only_latest_group, stale_clamped_to_default_window, stale_clamped_to_zero_for_latest_only, and the cache/eviction suite)
  • cargo test -p moq-mux --lib — 288 passed (publish→subscribe tests pass unmodified under the 5s default)
  • cargo test -p moq-audio (roundtrip + lib) and cargo test -p moq-bench — pass unmodified
  • cargo build -p moq-ffi -p libmoq clean; cargo test -p libmoq — 25 passed; the lone session_connect_and_close failure is environmental to the sandbox (real QUIC socket bind / IPv6 [::]:0 blocked, Os code 97), unrelated to this change
  • cargo test for hang / moq-loc / moq-msf / moq-json / moq-token — pass (validated pre-merge; dev's auto-merged changes only)
  • cargo fmt / cargo clippy clean on moq-net/moq-ffi/libmoq/moq-mux (only a pre-existing moq-native dependency warning)
  • js/net: bun test, tsc -b, biome check clean

moq-cli / moq-rtc / full moq-relay link and the moq-video/moq-gst crates can't fully build in this sandbox — they pull an egress-blocked git dep (nvidia-video-codec-sdk, 403) and system libs (libva, GStreamer, libsrt); moq-rtc/moq-relay additionally hit local disk-space limits mid-build. Validated everything else by temporarily neutralizing those deps; the committed diff (including the merge commit) has no Cargo.toml/Cargo.lock edits beyond what's inherited from dev. The moq-native/moq-relay socket-bind test panics are the same IPv6-[::]:0 sandbox limitation and pass in CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EviJwrDw3XZ9ZgESJGua28

(Written by Claude)

Add a `Cache` handle that retains old groups in RAM beyond a track's latest
one, bounded by a shared byte budget and wall-clock age. Attach it at the
origin, broadcast, or track level (cascading: track over broadcast over
origin); clone the handle to share one budget across many tracks.

Eviction is LRU by wall-clock last-access time (when a group was last served),
not by media timestamp or arrival order. A group is dropped once it exceeds
`max_age` since its last access, or once the shared total exceeds `max_bytes`
(least-recently-accessed first). A track's current max_sequence group is never
handed to the cache, so a live subscriber can always grab it.

Default behavior changes to latest-group-only: with no `Cache` attached a track
keeps only its latest group, replacing dev's arrival-window retention as the
local policy. The wire `TrackInfo::cache` Duration field is intentionally left
intact (its removal is a separate VOD discussion); the shared `Cache` is a
local-only retention object that supersedes it as the retention driver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EviJwrDw3XZ9ZgESJGua28
Expose the shared RAM LRU Cache through the FFI/C surface as a builder, and
remove the now-vestigial `TrackInfo::cache` field.

FFI (moq-ffi / libmoq):
- New `MoqCache` (uniffi Object) + `MoqCacheConfig` (Record: max_bytes, max_age_ms),
  with `MoqCache::new`, `clone_handle`, `is_clone`.
- `MoqBroadcastProducer::with_cache(&MoqCache)` and `MoqOriginProducer::with_cache(&MoqCache)`
  builder methods, mirroring moq-net's `with_cache`. A bare `MoqBroadcastProducer::new()`
  attaches its own default cache (64 MiB / DEFAULT_CACHE age) so an in-process consumer that
  lags the publisher does not lose superseded groups under the latest-only default.
- libmoq C: `moq_cache_create(max_bytes, max_age_ms)`, `moq_cache_close`,
  `moq_publish_with_cache(broadcast, cache)`; `moq_publish_create` attaches a default cache.
- Removes the `cache_ms` field from `MoqTrackInfo` (breaking; retention is a Cache policy now).

moq-net:
- Removes `TrackInfo::cache: Duration`, `with_cache(Duration)`, and its serde helpers. The
  field was local-only (never on either wire protocol); retention is governed by the shared
  Cache. `DEFAULT_CACHE` stays as the canonical default retention window.
- `clamp_stale` now derives from the attached Cache's `max_age`; with no cache (latest-only),
  the stale window collapses to `Duration::ZERO` since nothing beyond the latest group is kept.
- Adds `pub(crate) Cache::max_age()`.

Bindings + docs (Cross-Package Sync):
- py/moq-rs, swift, kt, go wrappers expose the Cache surface and `with_cache`, and drop
  `cache_ms`.
- js/net: drops the local-only `cache` field from `TrackInfo` (also never on its wire codec);
  local retention falls back to the `DEFAULT_CACHE_MS` window.
- doc/concept: reconciles the moq-lite retention prose (local cache policy, not a wire hint).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EviJwrDw3XZ9ZgESJGua28
@kixelated kixelated changed the title feat(moq-net): shared RAM LRU group cache feat(moq-net): shared RAM LRU group cache + FFI Cache surface Jun 24, 2026
claude added 3 commits June 24, 2026 06:02
The meta track's createTrack literal still set `cache`, which no longer exists
on TrackInfo after retention became a local Cache policy. Removing it; the track
falls back to the default local retention window like every other track.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EviJwrDw3XZ9ZgESJGua28
…ribe tests

These in-process tests publish several groups and only then create a consumer
that reads back the history. Under the latest-group-only default they lost all
but the final group. Attach an unbounded Cache to the broadcast/track so the
history is retained, mirroring production where the broadcast carries a cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EviJwrDw3XZ9ZgESJGua28
Revert the latest-group-only default to a backwards-compatible per-broadcast
cache: every BroadcastProducer (and every bare TrackProducer) now installs its
own default Cache with a 5-second age window and no byte cap, cascaded onto its
tracks. A late subscriber can replay the last few seconds, matching the
historical behavior. Each broadcast gets its own default instance, not a shared
global; an explicit with_cache at the origin/broadcast/track level still
overrides it and can share one budget across many.

cache::Config::default() is now { max_age: DEFAULT_CACHE (5s), max_bytes: 0 },
where max_bytes == 0 means no byte cap (eviction by age alone) and
max_age == Duration::ZERO disables retention (latest group only). clamp_stale
bounds a subscriber's stale window by the cache's max_age as before.

FFI/C: moq_publish_create (libmoq) and MoqBroadcastProducer::new (moq-ffi) no
longer attach a bespoke 64 MiB cache; they inherit moq-net's 5s default. The
MoqCache / with_cache handle stays as the opt-in for a custom window, a byte
budget, or a shared budget. MoqCacheConfig defaults to 5000 ms / 0 bytes.

Ripple: py/swift/kt/go wrapper docs updated (Go gains DefaultCacheConfig since a
zero-valued struct would otherwise mean latest-only); doc/concept/layer/moq-lite
describes the 5s default. js/net already implemented the 5s window. Reverts the
test cache-attaches from the prior latest-only commit; those tests now pass
unmodified under the default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EviJwrDw3XZ9ZgESJGua28
@kixelated kixelated changed the title feat(moq-net): shared RAM LRU group cache + FFI Cache surface feat(moq-net): shared RAM LRU group cache (5s/unbounded default) + FFI Cache surface Jun 24, 2026
claude and others added 2 commits June 24, 2026 15:35
…ru-dev

# Conflicts:
#	rs/moq-net/src/model/broadcast.rs
#	rs/moq-net/src/model/track.rs
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.

2 participants