Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ce30276
docs(moq-archive): add design proposal for track archival crate
claude Jun 20, 2026
4704202
docs(moq-archive): revise design per review
claude Jun 20, 2026
9304642
docs(moq-archive): add usage mockups, show the relay gap
claude Jun 20, 2026
d156900
docs(moq-net): add per-track cache spike; reconcile archive doc
claude Jun 21, 2026
17368b5
docs(moq-net): add moq-cli cache flag design to spike
claude Jun 21, 2026
18894ba
docs(moq-net): make the cache shareable across producer and consumer
claude Jun 21, 2026
8dd14bc
feat(moq-net): implement RAM tier of the per-track cache
claude Jun 21, 2026
0da1ab0
fix(moq-net): plain code spans in cache module doc to fix rustdoc
claude Jun 21, 2026
dea43d7
feat(moq-net): cache segment disk format and rollup compaction
claude Jun 21, 2026
8d0a720
feat(moq-net): multi-tier cache index and promotion orchestration
claude Jun 21, 2026
3f493c8
fix(moq-net): use slice::from_ref in cache index tests for clippy 1.96
claude Jun 21, 2026
38b6404
style(moq-net): rustfmt cache index test
claude Jun 21, 2026
65972c6
feat(moq-net): cache <-> live group bridges
claude Jun 21, 2026
407b5b5
feat(moq-net): wire the cache into TrackProducer and TrackConsumer
claude Jun 21, 2026
f3dcd61
feat(moq-net): disk and remote cache tiers over object_store
claude Jun 21, 2026
b769c66
build: update Cargo.lock for object_store
claude Jun 21, 2026
5aaf894
Merge remote-tracking branch 'origin/dev' into claude/moq-archive-pla…
claude Jun 21, 2026
71deaf2
fix(moq-net): address cache review findings
claude Jun 21, 2026
c3f35dc
refactor(moq-net): move cache fully into moq-net; target-gate the tiers
claude Jun 22, 2026
9e53d9f
feat(moq-net): wire the disk/remote tiers into the cache Config
claude Jun 22, 2026
33fc0c2
refactor(moq-net): move the cache into TrackState, reuse the live gro…
claude Jun 22, 2026
8f4d39f
feat(moq-net): chain upstream on a cache miss
claude Jun 22, 2026
3db189c
fix(moq-net): bound the cache flush backlog and only spill finished g…
claude Jun 22, 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
24 changes: 24 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ moq-vaapi = "0.0.2"
# opts in: binaries (libmoq, moq-boy, moq-cli) enable them, but a self-compiler can
# leave them off to drop the CUDA / libva deps. moq-video itself still defaults them on.
moq-video = { version = "0.0.4", path = "rs/moq-video", default-features = false }
object_store = { version = "0.12", default-features = false }
qmux = { version = "0.2", default-features = false }
serde = { version = "1", features = ["derive"] }
tokio = "1.48"
Expand Down
117 changes: 117 additions & 0 deletions rs/moq-net/CACHE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# moq-net track cache

A per-track durable cache. It lets a relay or edge keep recent groups past the live window and
serve them back on a FETCH, spilling to local disk and optionally remote object storage. It lives
in `moq-net` so any consumer of a track (relay, edge, archiver) gets durable caching for free.

## Shape

The cache is **not** a separate handle you wire onto both endpoints. It lives on the shared track
state (`TrackState`), so the RAM tier is the track's own live `groups` buffer and the disk/remote
tiers hang off the same state. One store therefore backs the track's `TrackProducer` and every
`TrackConsumer` automatically; a fetch is served from whichever tier holds the group.

```rust
// module moq_net::cache (native-only types are target-gated to non-wasm)

let disk = cache::Disk::new(store, prefix, bounds) // object_store + key prefix + bounds
.with_remote(remote); // optional rollup target

let producer = TrackProducer::new(name, info).with_cache(disk);
let consumer = producer.consume(); // shares the same store
```

## Principles

- **Local, not on the wire.** The cache is local policy set by whoever holds a track endpoint (the
relay or edge), never by the original publisher and never carried on the wire.
- **RAM is the live window.** There is no second in-memory copy of recent groups: the cache reuses
`TrackState.groups`, the buffer the track already keeps for live subscribers. A group is
serialized (to `cache::Group`) and handed to the disk tier only when it ages out of that window.
- **No traits, no callbacks.** The cache is concrete values you configure and attach. moq-net owns
all behavior; the disk and remote backends are a configured `object_store`, not a
consumer-implemented extension point.
- **Per-track, no shared LRU.** Each track keeps its own recent window; there is no cross-track
accounting, so no shared lock. Footprint is the sum of per-track windows across live tracks.

## Retention: two gates

A group is evicted from the live window (`TrackState::evict_expired`) when it trips **either** of
two gates, both sized by `TrackInfo::cache` (the publisher's retention duration). The newest group
(`max_sequence`) is never evicted.

- **Wall-clock** — the group was *received* more than the window ago. The receive time is an
`Instant` stamped when the group lands in `groups`; it is never sent over the wire or set by the
publisher. This is the hard memory backstop: a publisher can't pin RAM by lying about media
timestamps.
- **Media-time** — the group's last frame timestamp is more than the window behind the live media
edge (the newest frame timestamp buffered). This bounds a startup stampede, where a burst of
buffered media arrives at once (all "received now", so the wall-clock gate alone would keep it
all) and a fresh subscriber would otherwise be flooded.

In steady state, where media time advances with wall-clock time, the two gates coincide. They
diverge only under a stampede (media-time trims it) or timestamp abuse (wall-clock trims it).

## Spill and serve

```text
evict_expired: (synchronous, under the state lock)
for each group outside the window (not max_sequence):
tombstone it in `groups`
if a cache is attached: hand its live GroupConsumer to the flush task

flush task: (one background task per cached track)
per eviction pass: drain the groups into cache::Group, write ONE disk segment,
then compact (roll the oldest disk segments up into one remote object, or evict
them when there is no remote tier)

fetch_group(seq):
live hit in `groups` -> serve immediately
live miss, cache attached -> spawn an async disk/remote lookup; a hit
resolves the fetch, a miss chains upstream
(queues for a TrackDynamic), else NotFound
live miss, no cache -> queue for a TrackDynamic, or NotFound
```

`get_group(seq)` stays synchronous and only consults the live window; a spilled group is reachable
only through the async `fetch_group`.

On a tier miss the lookup task chains upstream: it queues the request for a `TrackDynamic` (a wire
FETCH for a relay) when one exists, so the fetch then resolves once upstream serves the group into
the live window. Queuing only *after* the store misses keeps the store the fast path and avoids a
redundant upstream fetch when the group is already cached. With no handler, a miss is `NotFound`.

Batching the disk write per eviction pass keeps a stampede-trim (many groups evicted at once) to a
single object. A steady-state single eviction still writes one small disk segment per group; the
remote tier is where rollup (`segment::rollup`) concatenates those into large objects, so a
per-frame (audio) track does not litter object storage with tiny remote objects.

## Tiers and the byte format

RAM is always present and dependency-free. Disk and remote are `object_store`, target-gated to
non-wasm targets (`cfg(not(target_arch = "wasm32"))`) so native builds get the tiers with no flag
and wasm builds drop the server-side cloud stack automatically.

The on-disk format lives in `segment.rs`: a band of groups serialized as one self-describing
object (a footer offset table read from a fixed trailer), lossless per-frame timestamps (raw
value + scale, so any timescale round-trips), `rollup` to concatenate small segments into one
larger object, and `group_from_blob` for the ranged-read decode path. `index.rs` is the
storage-agnostic multi-tier index (`sequence -> (tier, segment, byte range)`), per-tier byte and
duration accounting, and the promotion that picks the oldest disk segments over the disk high
watermark. `store.rs` is the `object_store` glue tying them together. The disk `Bounds` (a
low/high watermark) govern when disk segments roll up to remote, independent of the RAM retention
window above.

## Bridging live <-> cached

`cache::Group::read` drains a finished live `GroupConsumer` into the serializable `cache::Group`
(done on the flush task, off the state lock). `cache::Group::produce` rebuilds a live
`GroupConsumer` from a stored group at the track's timescale, for serving a fetch.

## Still design

- **Removing `TrackInfo::cache`.** The retention window is still read from the wire-carried
`TrackInfo::cache`. Making retention purely local policy (and dropping the wire field) is a
separate wire change.
- **moq-cli / moq-relay flags.** Surfacing `with_cache` as CLI/TOML configuration (a disk path, a
remote URL, bounds) is follow-up work; the model API is in place.
6 changes: 6 additions & 0 deletions rs/moq-net/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ tracing = "0.1"
web-async = { workspace = true }
web-transport-trait = { workspace = true }

# The disk/remote cache tiers use object_store, a server-side library that doesn't build for
# wasm (browsers don't spill to disk/S3). Target-gated rather than feature-gated so native builds
# get the tiers automatically with no flag, and wasm builds drop them automatically.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
object_store = { workspace = true }

[dev-dependencies]
# test-util (tokio::time::pause/advance) is test-only and is NOT supported on
# wasm, so it must not leak into the normal dependency feature set.
Expand Down
Loading
Loading