Skip to content

docs: design for track archival + per-track moq-net cache#1841

Open
kixelated wants to merge 18 commits into
devfrom
claude/moq-archive-planning-r7xgqp
Open

docs: design for track archival + per-track moq-net cache#1841
kixelated wants to merge 18 commits into
devfrom
claude/moq-archive-planning-r7xgqp

Conversation

@kixelated

@kixelated kixelated commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

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 via object_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 to TrackProducer.

The per-track cache (settled shape)

The relay/edge integration is not a Cache trait moq-net calls back into (rejected: no inversion of control). Instead:

  • A concrete CacheConfig owned by TrackProducer only — local policy, never on the wire, never the original publisher's concern. TrackInfo.cache is removed.
  • Per-track [min, max] bounds on size and duration. No shared/global LRU, so no shared lock; footprint is sum of per-track max.
  • Watermark flush: groups accumulate to the high mark, then the max − min band flushes as one segment. This is the property an LRU lacks — it would emit one tiny object per group (fatal for audio). An interval backstop covers low-rate tracks.
  • Tiers RAM → disk → remote via 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.
  • Served by ranged read from whichever tier holds the group; no fault-in.

Related design threads captured (not yet built)

  • Splitting Origin into Announced (routing table, multi-connection) + registerable dynamic() handlers, so moq-relay/moq-cli/moq-edge compose without traits and the relay stays ~thin. The cache config threading onto auto-created tracks is part of this follow-up.
  • Per-broadcast/track billing enablement (JWT archive claim + server-side policy + optional publisher signal).

Test plan

  • N/A — documentation only. Review focuses on the cache shape (per-track bounds + watermark flush), removing TrackInfo.cache, the archive segment/manifest format, and the open questions before any code lands.

🤖 Generated with Claude Code

(Written by Claude)

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
Comment thread rs/moq-archive/DESIGN.md
the final frame count. This is our "safe to flush" signal.

**Serving side (produce a track + answer fetches):**
- `TrackProducer::dynamic() -> TrackDynamic`.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think we just take a TrackDynamic.

Comment thread rs/moq-archive/DESIGN.md Outdated
- `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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Comment thread rs/moq-archive/DESIGN.md
prefix and namespaced by broadcast/track:

```
<root>/<broadcast>/<track>/segments/<segment-id> # concatenated groups

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The broadcast and track names can have slashes. I guess we need to escape them.

Comment thread rs/moq-archive/DESIGN.md Outdated

## Open questions

1. **`frame_start` granularity.** moq-lite-05 FETCH can request "group N starting at frame K".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We might remove this from moq-lite-05. The current API doesn't even support it.

Comment thread rs/moq-archive/DESIGN.md Outdated
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread rs/moq-archive/DESIGN.md Outdated
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We always keep the latest group in RAM.

Comment thread rs/moq-archive/DESIGN.md Outdated
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator Author

Folded all the review comments into the latest commit:

  • Take a TrackDynamic (not a TrackProducer): the archive is now a composable link in a cache chain (relay → archive → origin); caller owns the producer.
  • Miss fallback / chaining: on a storage miss the archive forwards to an upstream TrackConsumer instead of returning NotFound; whether this should instead live inside moq_net core types is now the lead open question (replied on that thread for your call).
  • Rollup: each tier concatenates multiple units from the tier above (RAM 30s → 10s disk segments → 1m S3 objects), so fragmentation drops downward. Resolves the old 1:1-vs-concatenate question toward concatenate-at-every-rollup.
  • Eviction: LRU + byte budget per tier, not just age; always keep the latest group in RAM; use used()/unused() to flush unused groups early.
  • Index format nailed down: per-segment postcard footer (group offset table) + per-track manifest, Parquet/Iceberg-shaped, replacing the JSONL sketch.
  • Slashes: percent-encode broadcast/track names into single opaque path segments.
  • Sub-group frame_start: dropped (likely removed from moq-lite-05, unsupported by the current API); the archive serves whole groups.

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

Copy link
Copy Markdown
Collaborator Author

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 serve(TrackDynamic) today, because the archive is the publisher: it publish_broadcasts, and for each requested_track it accepts the producer, takes producer.dynamic(), and serves from storage. No path collision since the live broadcast is gone.

B. Why moq-relay can't use it as-is — the relay only holds a single OriginProducer and forwards whole broadcasts; it never constructs a TrackProducer or calls .dynamic(). That happens inside moq-net's lite/ietf subscriber fan-out, so there's no point in relay code to hand the archive a TrackDynamic. Forcing it would mean republishing every broadcast through an archive-owned BroadcastProducer and re-accepting every track, i.e. reimplementing fan-out around the archive.

C. The moq-net seam (recommended) — a pluggable trait Cache that moq-net consults on a miss and notifies on eviction, attached at the origin where it already owns the per-track RAM cache. Then the whole relay integration is:

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 Archive becomes a Cache impl plus the standalone record/serve helpers.

This is the same fork as the open question I flagged, now made concrete: do you want me to redesign the API around a moq_net::Cache trait (relay-friendly, needs a small moq-net addition), or keep the per-track chain version (standalone/VOD only for now)? I'd recommend the Cache trait. Pushed in 93046420.

(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
@kixelated kixelated changed the title docs(moq-archive): design proposal for track archival crate docs: design for track archival + per-track moq-net cache Jun 21, 2026
claude added 14 commits June 21, 2026 16:24
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
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

Copy link
Copy Markdown
Collaborator Author

Extensive review (self-review with three finder passes + verification)

Ran a high-recall review over origin/dev...HEAD (correctness, removed-behavior, cross-file, reuse/simplification/efficiency, altitude, conventions). Fixes pushed in 71deaf25. Below: the public-API surface, what was fixed, and two open design calls for you.

Public API surface (all additive → not breaking; dev is correct)

New under moq_net::cache (pub mod cache):

  • Config (#[non_exhaustive], Default), Bounds, Limit, Frame, Group, Batch
  • Producer (intentionally not Clone), Consumer (Clone); Group::read / Group::produce bridges
  • cache::segment: encode, rollup, group_from_blob, Segment, GroupEntry (#[non_exhaustive]), Error
  • cache::store (behind cache-tiered): Store, Error
  • TrackProducer::with_cache, TrackConsumer::with_cache

No existing signature changed. The one planned breaking change, removing the wire field TrackInfo.cache, is still pending and is dev-appropriate.

Correctness findings — fixed

  1. Disk tier could wipe itself. index::promotion drained the entire disk tier when the low watermark was unset (floor of zero), so one over-max trip evicted even the newest flushed groups on the no-remote path. Now keeps the newest segment, mirroring the RAM tier's "keep latest" rule.
  2. Crash-consistency. store::flush/compact mutated the index before the put/upload. A failed write stranded sequences pointing at a nonexistent object. Reordered to write-then-index (added Index::next_id() to reserve the key); a failure now leaves the index intact.
  3. Overflow guard. store::get computed offset + length unchecked; a corrupt footer could overflow u64. Now checked_add, matching segment::blob.
  4. fetch vs get inconsistency. fetch_group's cache path surfaced a produce() rebuild error (e.g. timescale mismatch on a not-yet-accepted wire consumer) as a hard fetch failure, while get_group treated it as a miss. Now both rebuild synchronously and miss-through on error.

Cleanup — applied

  • store_of now expects a configured remote tier instead of silently reading disk for a remote location.
  • index is internal orchestration (only the gated store uses it) → gated behind cache-tiered and dropped from the public surface; segment::GroupEntry got #[non_exhaustive].
  • Varint coding correctly reuses VarInt::{encode,decode}_quic (not duplicated); both error enums use thiserror + #[from] + #[non_exhaustive]; no em dashes.

Two open decisions for you (not unilaterally changed)

A. TrackProducer::with_cache populate disables demand teardown. The populate path subscribes internally, so producer.unused() never resolves while a cache is attached, and a relay that drops idle tracks by demand (lite/ietf subscriber, session.rs) won't drop a cached one. That's intended for "keep recording when idle" (archive/DVR), but wrong for a transient relay cache. Options: keep as-is (documented now); populate on group eviction instead (no subscription, but needs a sync frame-snapshot primitive on groups); or populate from the relay's existing fan-out consumer. This is the same lifecycle question I flagged earlier — worth settling before wiring the relay.

B. Same populate loop head-of-line-blocks on each group's finish (Group::read resolves only when a group finishes), so a stalled group delays caching of later finished ones. Fixable with concurrent reads (FuturesUnordered) if we keep the subscribe approach — I held off pending the decision in A, since the populate design may change.

Both caveats are now documented on with_cache. Everything else is fixed; default suite 427 pass, cache-tiered 39 pass, clippy/rustdoc/fmt clean.

(Written by Claude)


Generated by Claude Code

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