docs: design for track archival + per-track moq-net cache#1841
docs: design for track archival + per-track moq-net cache#1841kixelated wants to merge 18 commits into
Conversation
Design-only first pass for a new moq-archive binary/library that records a single track to tiered storage (RAM -> disk -> S3 via object_store) and serves old groups back through the moq-lite-05 FETCH path (TrackDynamic). Covers the segment/index on-disk format, out-of-order group handling, per-tier optional retention measured by media timestamp with wall-clock fallback, the public API sketch, and open questions. No implementation yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
| the final frame count. This is our "safe to flush" signal. | ||
|
|
||
| **Serving side (produce a track + answer fetches):** | ||
| - `TrackProducer::dynamic() -> TrackDynamic`. |
There was a problem hiding this comment.
I think we just take a TrackDynamic.
| - `GroupRequest::sequence() -> u64`, `GroupRequest::accept(info) -> GroupProducer`. We fill the | ||
| returned `GroupProducer` with `create_frame` / `write` / `finish` from storage, then | ||
| `GroupProducer::finish()`. | ||
| - To expose the track over a session, wrap the producer in a `BroadcastProducer` and publish |
There was a problem hiding this comment.
Note the caller will do this. For example, moq-relay might first try to use moq-archive to handle any dynamic requests.
I think moq-archive also needs it's own dynamic() to make that work? Because if something isn't on disk or on S3, we'll have to ask the original publisher for it.
That might be an argument for stuffing this logic directly into moq_net::TrackProducer and moq_net::TrackConsumer? It feels a bit gross but IDK the API is far more friendly. We would also definitely know when data is evicted from RAM cache too.
There was a problem hiding this comment.
Captured this as the lead open question and kept v1 wiring the chain in moq-archive (caller owns the TrackProducer, archive takes the TrackDynamic plus an upstream TrackConsumer to forward misses + record). Agreed the moq-net-native version is the friendlier API and the only one that reliably knows when a group is evicted from the RAM cache, which is the natural flush trigger.
The way I'd frame the decision: do we want moq_net::TrackProducer to accept a pluggable storage backend (a trait Cache it consults on a miss and notifies on eviction), so the archive becomes "attach storage to a track" rather than "wire a fallback chain"? That keeps storage code out of moq-net but gives moq-net the one hook (eviction) we can't get from the outside. If you're good with that shape I'll redesign the public API around it before any implementation; if you'd rather keep moq-net pure for now, the chain version stands. Which way do you want to go?
(Written by Claude)
Generated by Claude Code
| prefix and namespaced by broadcast/track: | ||
|
|
||
| ``` | ||
| <root>/<broadcast>/<track>/segments/<segment-id> # concatenated groups |
There was a problem hiding this comment.
The broadcast and track names can have slashes. I guess we need to escape them.
|
|
||
| ## Open questions | ||
|
|
||
| 1. **`frame_start` granularity.** moq-lite-05 FETCH can request "group N starting at frame K". |
There was a problem hiding this comment.
We might remove this from moq-lite-05. The current API doesn't even support it.
| Need a crash-consistency story: write the segment object first, then its index entries, so a | ||
| half-written segment is simply never indexed (and is GC'd by a startup sweep of unindexed | ||
| segments). | ||
| 3. **Aggregation/compaction shape.** When promoting disk -> S3, do we copy segments 1:1 or |
There was a problem hiding this comment.
I think we should merge multiple groups at each rollup step. So RAM can be fragmented, but each rollup becomes less fragmented.
ex. store for up to 30s in RAM, flush in 10s segments to disk. Store on disk for up to 5m, flush to S3 in 1m batches?
IDK if we want to get an LRU involved or we just eyeball RAM usage though. I feel like an LRU is correct in general for both RAM and disk though. We could use the used() and unused() state too I guess. No sense keeping unused stuff in RAM if we could combine it with the next flush to disk.
| concatenate many small disk segments into one big S3 object (rewriting offsets in the | ||
| index)? Concatenation is better for S3 request economics but adds a rewrite step. Lean | ||
| toward 1:1 in v1, compaction later. | ||
| 4. **Serving the *latest* group / live edge.** v1 answers FETCH for past groups. Should the |
There was a problem hiding this comment.
We always keep the latest group in RAM.
| 4. **Serving the *latest* group / live edge.** v1 answers FETCH for past groups. Should the | ||
| archive also serve a live `subscribe` (replay newest groups as they land) so it can stand in | ||
| for a departed origin? That is closer to DVR and probably a follow-up. | ||
| 5. **Index for a hot, long archive.** A multi-day archive has a large index. JSONL + in-RAM |
There was a problem hiding this comment.
We should try to nail down the index file format for sure.
Address the review on the planning doc: - serve() takes a TrackDynamic, not a TrackProducer; archive is a composable link in a cache chain (relay -> archive -> origin) and forwards storage misses to an upstream TrackConsumer. Note the open question of folding this into moq_net core types. - Tiering reworked around progressive rollup: each step concatenates multiple units from the tier above (RAM 30s -> 10s disk segments -> 1m S3 objects), so fragmentation drops downward. Resolves the 1:1-vs-concatenate question. - Eviction is LRU + size budget, not just age; always keep the latest group in RAM; use moq-net used()/unused() to flush unused groups early. - Nail down the index format: per-segment postcard footer + per-track manifest (Parquet/Iceberg shaped), replacing the JSONL sketch. - Percent-encode broadcast/track names since they contain slashes. - Drop sub-group frame_start (likely removed from moq-lite-05; unsupported by the current API); serve whole groups. - Add a prior-art survey (BookKeeper, Kafka KIP-405, Haystack/Bitcask, Parquet/Iceberg) and why no single embeddable crate covers batch+index+S3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
|
Folded all the review comments into the latest commit:
Also added a prior-art survey (BookKeeper ledgers, Kafka KIP-405 segments, Haystack/Bitcask, Parquet/Iceberg) and why no single embeddable crate covers batch + index + S3 tiering, in answer to the earlier "is there an existing library?" question. (Written by Claude) Generated by Claude Code |
Add a Usage section with concrete call sites: - Scenario A: a standalone VOD node serving a recorded broadcast back; the per-track serve(TrackDynamic) API fits because the archive is the publisher. - Scenario B: why moq-relay cannot use the per-track API as-is. The relay only holds an OriginProducer and forwards whole broadcasts; the per-track TrackProducer + dynamic() is created inside moq-net's subscriber fan-out, so there is no seam to hand the archive a TrackDynamic. - Scenario C: the recommended moq-net seam, a pluggable Cache trait consulted on a miss and notified on eviction, attached to the origin, reducing the relay integration to one line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
|
Added a Usage mockups section with concrete call sites, and you're right that the relay doesn't fit the per-track API. Three scenarios: A. Standalone / VOD node — works with B. Why moq-relay can't use it as-is — the relay only holds a single C. The moq-net seam (recommended) — a pluggable let origin = Origin::random().produce().with_cache(archive);moq-net catches evictions (the natural flush trigger, which the chain version can't see) and serves misses before they cost an upstream FETCH. The public This is the same fork as the open question I flagged, now made concrete: do you want me to redesign the API around a (Written by Claude) Generated by Claude Code |
Add rs/moq-net/CACHE.md: a spike for a per-track group cache owned by TrackProducer. Concrete CacheConfig value (no trait, no callback), per-track [min, max] bounds on size and duration, watermark flush that batches the max-min band into one segment (which an LRU cannot do), RAM -> disk -> remote tiers via object_store behind a feature flag, served by ranged read with no fault-in. Removes the wire-visible TrackInfo.cache in favor of local, producer-owned policy. Adds an interval flush backstop for low-rate tracks. Reconcile rs/moq-archive/DESIGN.md scenario C and open question 1 to the concrete cache (drop the rejected Cache trait sketch) and cross-link the spike. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
Sketch the CacheArgs clap group for `moq serve` / `moq accept`: --cache-ram (+ -min), --cache-disk(-age), --cache-remote(-age), --cache-interval, mapping onto the per-track [min, max] bounds and the RAM -> disk -> remote cascade. Absent --cache-ram leaves caching off. Design only; wiring waits on the moq_net::CacheConfig API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
The cache becomes a cloneable `Cache` handle (built from CacheConfig) whose clone shares the same tiers, so one cache can back both a track's TrackProducer and its TrackConsumer. Add TrackConsumer::with_cache and spell out the consumer fetch semantics: fetch_group / get_group resolve from the cache first (RAM sync, disk/remote after a ranged read), miss falls through to the wire and populates the cache, and live subscribe groups populate it too. A cache-backed consumer with no upstream answers FETCH straight from storage (the archive serve path). Inserts dedup by sequence so sharing one cache across both sides is safe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
Add the moq_net::cache module: a per-track bounded group cache with the produce/consume split. cache::Config::produce() yields a cache::Producer (write half, not Clone) and Producer::consume() a cache::Consumer (read half, Clone), sharing one store so a cache backs both a track's producer and consumer. Eviction is a high/low watermark, not an LRU: an insert over the high watermark drains the oldest groups down to the low watermark and returns them as one Batch (the caller persists it to the next tier), which is what lets a group-per-frame audio track avoid one tiny object per group. Bounds are per-track on bytes and duration (media-timestamp span), the latest group is never evicted, and inserts dedup by sequence so sharing one cache across both endpoints is safe. 14 unit tests cover get/miss, dedup, byte and duration watermarks, batch contents, always-keep-latest, hysteresis within the band, unbounded and min-unset edges, and out-of-order inserts. Disk/remote tiers and the TrackProducer/TrackConsumer with_cache wiring remain design; CACHE.md is reconciled to the implemented names and marks what is built. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
The module-level //! doc used intra-doc links to same-module items (Batch, Producer, Consumer), which rustdoc cannot resolve from an inner module doc (even with self:: paths), failing `cargo doc -D warnings` in CI. Use plain code spans there; item-level links are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
Add cache::segment: the on-disk byte format for the cache's disk/remote tiers
and the rollup that compacts small segments into one larger object.
A segment is one band of groups (a Batch) serialized as group blobs back to
back, a footer offset table, then a fixed 8-byte trailer (footer length +
magic). The trailer being last and fixed-size lets a reader fetch it with one
tail-ranged GET, parse the footer, then fetch just the byte range of the wanted
group. Each blob is self-delimiting (frame count, then length-prefixed frames
carrying their optional media timestamp). Timestamps store raw value+scale, so
a non-micro timescale (e.g. 90kHz video) round-trips exactly. Reuses the QUIC
VarInt codec. rollup copies group blobs verbatim and rewrites offsets, so it is
lossless and does not re-encode frames.
To serialize losslessly, cache::Group now carries per-frame timestamps
(cache::Frame { timestamp, payload }) with ts_first()/ts_last() derived for the
duration bound; the RAM tier and its tests are updated accordingly. The module
becomes a directory (cache/mod.rs + cache/segment.rs).
12 new segment tests (batch/single round-trip, footer summary, lossless
non-micro scale, mixed/absent timestamps, empty group and empty batch, missing
sequence, bad magic, truncation, rollup concat + offsets + single-segment +
corrupt-input). All 415 moq-net lib tests pass; clippy and rustdoc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
Add cache::index: the storage-agnostic layer that ties the segment format and rollup into serving across tiers. It maps each group sequence to a Location (tier + segment + byte range), so a fetch is "locate, then ranged-read that segment." It tracks per-tier byte and duration totals, and drives promotion: Index::promotion picks the oldest disk segments once the disk tier is over its high watermark (draining to the low watermark, oldest first), and Index::apply_promotion registers the rolled-up remote segment, repoints those sequences at the remote tier, and drops the promoted disk segments. The index holds only metadata, never group bytes, so it is the part that stays in memory while bytes live on disk/remote. Add segment::group_from_blob, the ranged-read decode entry point (decode one group from just its blob bytes), and Segment::byte_len for tier accounting. The remaining object_store put/get_range glue is a thin layer over these decisions. 7 index tests including an end-to-end check: encode segments, locate, ranged- read via group_from_blob, then promote (rollup) and confirm every group still decodes identically through the remote segment. Full moq-net suite 422 pass; clippy and rustdoc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
The cloned_ref_to_slice_refs lint (rust 1.96) flags &[x.clone()]; use std::slice::from_ref(&x) instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
Wrap the long rollup line flagged by cargo fmt --check in CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
Add cache::Group::read (async: drain a live GroupConsumer into a cache::Group, reading each frame's payload and timestamp, resolving on group finish) and cache::Group::produce (sync: rebuild a live GroupConsumer from a cache::Group, validating frame timestamps against the track timescale). These are the bridge the TrackProducer populate path and TrackConsumer serve path both use. Two async round-trip tests (timed and untimed groups): live -> cache -> live -> cache preserves sequence, payloads, and per-frame timestamps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
TrackProducer::with_cache(cache::Producer) spawns an internal subscriber that drains each finished group into the cache (producer fills). The subscription keeps the track active while caching, independent of downstream demand. TrackConsumer::with_cache(cache::Consumer) attaches a read-through cache: get_group and fetch_group resolve from it on a live-state miss, rebuilding the group at the track's timescale. fetch_group serves from the cache before failing with NotFound or waiting on a TrackDynamic, via a pre-resolved branch added to TrackFetch. So a consumer sharing the producer's cache supports fetch without a wire round-trip. Three tests: producer fills the cache and a shared reader sees the group; get_group and fetch_group fall through to the cache and read back byte-for-byte. Full moq-net suite 427 pass; clippy and rustdoc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
Add the cache-tiered feature (off by default so RAM-only and wasm builds stay dependency-free) and cache::store::Store, the object_store glue over the index: - flush(batch): segment::encode the band, put it as one disk segment, record it in the index, then compact. - get(seq): index.locate -> get_range the blob -> segment::group_from_blob. - compact(): when the disk tier is over its bounds, read the oldest segments, segment::rollup them into one remote object, apply_promotion to repoint the index, and delete the disk objects; with no remote tier, evict them instead. object_store is added default-features = false (core + memory + local fs, no cloud SDKs). Add Index::evict for the no-remote eviction path. 5 tests against object_store::memory::InMemory: flush/get round-trip, promotion to remote preserving all groups, eviction of the oldest without a remote, plus a non-gated Index::evict unit test. Default suite 428 pass; with cache-tiered, 39 cache tests pass; clippy/rustdoc/fmt and --no-default-features clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
…nning-r7xgqp # Conflicts: # Cargo.toml
Correctness: - index::promotion never selects the single newest disk segment (mirrors the RAM "keep latest" rule). Without it, an unset low watermark drained the whole disk tier on one over-max trip -> data loss of recent groups on the no-remote eviction path. - store crash-consistency: write the object before mutating the index. flush puts the disk segment under index.next_id() then add()s; compact uploads the rolled remote object before apply_promotion. A failed put/upload now leaves the index (and the disk segments it points at) intact instead of stranding sequences on a nonexistent object. Added Index::next_id() for the peek. - store::get guards offset+length with checked_add (a corrupt footer could overflow u64 and produce a bad range), matching segment::blob. - TrackConsumer::fetch_group rebuilds the cached group synchronously and treats a rebuild error as a miss (falling through to the live path), consistent with get_group. Previously a produce() error (e.g. timescale mismatch on a not-yet-accepted wire consumer) surfaced as a hard fetch failure. TrackFetch now holds the rebuilt GroupConsumer. Cleanup: - store_of expects a configured remote tier for a remote location instead of silently falling back to disk. - index is internal orchestration: gate it behind cache-tiered (its only user is the gated store) and drop it from the public surface; segment::GroupEntry gets #[non_exhaustive]. - Document the two with_cache caveats the review surfaced (the internal subscriber disables demand teardown; a stalled group head-of-line-blocks caching of later finished groups). Default suite 427 pass; with cache-tiered, 39 cache tests pass. clippy (both feature sets), rustdoc, and fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8gBynFAeeVnxuffr4ZKUo
Extensive review (self-review with three finder passes + verification)Ran a high-recall review over Public API surface (all additive → not breaking;
|
Summary
Design-only (no implementation) for saving a MoQ track to durable storage and serving it back, plus the moq-net primitive it rides on. Two docs, refined through review:
rs/moq-archive/DESIGN.md— the archive crate: record a track, serve old groups back through FETCH, tier RAM → disk → S3 viaobject_store, retention by media timestamp with wall-clock fallback. Includes the segment + manifest format, out-of-order group handling, usage mockups (standalone/VOD vs why the relay needs a different seam), and a prior-art survey (BookKeeper, Kafka KIP-405, Haystack/Bitcask, Parquet/Iceberg).rs/moq-net/CACHE.md— the per-track cache spike the archive builds on (see below).Targets
dev: it removes a public/wire field (TrackInfo.cache) and adds local API toTrackProducer.The per-track cache (settled shape)
The relay/edge integration is not a
Cachetrait moq-net calls back into (rejected: no inversion of control). Instead:CacheConfigowned byTrackProduceronly — local policy, never on the wire, never the original publisher's concern.TrackInfo.cacheis removed.[min, max]bounds on size and duration. No shared/global LRU, so no shared lock; footprint is sum of per-trackmax.max − minband flushes as one segment. This is the property an LRU lacks — it would emit one tiny object per group (fatal for audio). Anintervalbackstop covers low-rate tracks.object_store, feature-gated so RAM-only stays dependency-free. On-tier bytes reuse the archive segment format, so a relay's spill is directly readable by an archive node.Related design threads captured (not yet built)
OriginintoAnnounced(routing table, multi-connection) + registerabledynamic()handlers, somoq-relay/moq-cli/moq-edgecompose without traits and the relay stays ~thin. The cache config threading onto auto-created tracks is part of this follow-up.archiveclaim + server-side policy + optional publisher signal).Test plan
TrackInfo.cache, the archive segment/manifest format, and the open questions before any code lands.🤖 Generated with Claude Code
(Written by Claude)