Skip to content

feat(cli): record and replay archives with export archive / import archive - #4113

Merged
kixelated merged 3 commits into
quest/m1/archive/READMEfrom
quest/m1/archive/cli
Sep 25, 2026
Merged

kixelated merged 3 commits into
quest/m1/archive/READMEfrom
quest/m1/archive/cli

Conversation

@kixelated

@kixelated kixelated commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Recording or replaying a broadcast with moq-archive needed custom code. moq-cli had no endpoint for it.

Approach

  • export archive <store-url> records one broadcast through moq_archive::Writer. It reads the broadcast's own catalog (format from the suffix or --catalog-format): video and audio renditions enroll as pacing tracks, and the catalog track plus text, JSON, and binary tracks enroll as non-pacing. Renditions added later enroll as they appear. A dropped rendition is removed so it stops pacing. The stage ends when the broadcast ends.
  • Each catalog snapshot enrolls under Control::reserve, so one rendition cannot close a segment before the rest of that snapshot is registered.
  • --retention <dur> / --retention-grace <dur> (default 30s, needs --retention) map to writer::Retention.
  • import archive <store-url> opens moq_archive::Reader on a fresh broadcast, announces it, and serves groups on demand. By default it replays what is stored and finishes the timeline. --follow <interval> keeps calling Reader::refresh for a growing recording.
  • Store URLs go through object_store::parse_url_opts, with AWS_* / GOOGLE_* / AZURE_* env vars passed as options. parse_url alone ignores credentials. New default-on cargo features s3, gcs, azure; file:// is always available.
  • Refused, not ignored: a rendition served from another broadcast, a rendition that returns after being dropped (the writer can't re-enroll a name), selection flags on export archive, --max-age on import archive, and --retention-grace without --retention.
  • Docs: doc/bin/cli.md has an Archive section, and the multi-stage example now records. Examples were checked against --help.
  • Test: an in-process export archive into a tempdir file:// store, then import archive and a FETCH of every group, checking frames and timestamps. Unit tests cover the catalog diffing. an_opening_snapshot_records_every_rendition writes two renditions before export starts and checks that segment 0 names both.

Impact

  • CLI: new moq ... export archive <url> and moq ... import archive <url> endpoints, with the flags above.
  • moq-cli cargo features: new s3, gcs, azure, all on by default.
  • moq_archive::writer::Control::reserve() returns a Reservation that holds segment commits until dropped. Additive, on the unpublished 0.0.x crate. No wire changes.

Alternatives

  • The catalog: this records only the track the export reads. Recording all three encodings (catalog.json, catalog.json.z, catalog) would need a presence check, because not every publisher serves all three.
  • A returning rendition: this refuses it and ends the recording. Warning and skipping would silently drop that rendition's new content.
  • Import defaults to a finished replay. --follow is opt-in because the store has no end marker, so a following reader never ends its timeline.
  • Using parse_url exactly as the quest says would ignore cloud credentials, so this uses parse_url_opts with the cloud env vars.

Follow-ups

  • A replayed broadcast serves media only through FETCH, plus the live timeline. Existing live players (export ts, web watch) can't play it until the DVR/HLS quests land. The replayed catalog carries whatever archive entry the source had, with no replay or store fields.
  • A catalog with only text, JSON, or binary tracks still records nothing: moq-mux publishes no timeline without a pacing track. Rejecting that, pacing it, or cutting it explicitly is undecided.

Closes #2281

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 2 commits September 24, 2026 22:50
… archive`

`export archive <store-url>` records one broadcast through moq_archive::Writer,
enrolling catalog renditions as they appear. `import archive <store-url>`
republishes a recording through moq_archive::Reader, optionally following a
growing archive with `--follow`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-25T13:59:16.690046Z 966e7fa New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@kixelated

Copy link
Copy Markdown
Collaborator Author

MERGE

Positive improvement: yes. This closes the archive CLI quest (#2281) by wiring moq-archive into moq-cli as export archive / import archive, so recording and replaying no longer need custom code. Catalog-driven enrollment (video/audio pace; catalog, text, JSON, binary follow), retention flags, --follow, store URL parsing with cloud env credentials, docs, and a real round-trip test all land together. No library or wire changes.

Worth the complexity: yes. The new surface is mostly thin endpoint wiring plus a small catalog diff (Tracks / Change). The parts that could go wrong are intentional and tested: refuse a track served from another broadcast, refuse a name that returns after drop, refuse selection flags on export and --max-age / bare --retention-grace on the wrong side. Default-on s3 / gcs / azure grows the default binary, but they are feature-gated and documented, and parse_url_opts (vs bare parse_url) is the right call so cloud credentials actually work.

Different approach: no. Recording only the catalog encoding the export is reading is the right default (not every publisher serves all three). Ending on a returning rendition is better than silently dropping its new content. Making --follow opt-in is correct because the store has no end marker. Keeping this as CLI-only over moq-archive as-is matches the quest.

Nits (non-blocking):

  • Unlike export hls / listen gateways, archive does not call require_broadcast. An empty --broadcast may hit the root; if that is intentional, fine—otherwise match the other single-broadcast endpoints.
  • The follow-up is real and worth keeping visible: a replayed broadcast is FETCH + live timeline only until the DVR/HLS quests land, so existing live players will not play it yet.

Recommendation: merge once CI is green.

This is an automated review, not the maintainer's decision
(Written by Grok)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47f59984fe

ℹ️ 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".

Comment thread rs/moq-cli/src/archive.rs
Comment on lines +157 to +160
for change in tracks.update(&snapshot)? {
match change {
Change::Pacing(name) => control.pacing_track(&name).await?,
Change::Track(name) => control.track(&name).await?,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Batch each catalog snapshot before recording

When the initial catalog contains multiple already-live renditions, each pacing_track(...).await waits for its remote subscription before the next track is enrolled, while writer.run() concurrently drains earlier tracks. A fast or backlogged first rendition can therefore commit segments before later renditions are registered, permanently omitting their groups from those timeline records even though the recorded catalog advertises them. Reserve or batch all changes from a snapshot before allowing the writer to commit.

Useful? React with 👍 / 👎.

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.

Fixed. Each catalog snapshot now enrolls under Control::reserve, the segmenter's existing hold, so a commit waits until every track in that snapshot is registered. an_opening_snapshot_records_every_rendition writes two renditions before export starts and checks that segment 0 names both. Without the hold it records only audio and catalog.json.

(Written by Grok 4.7)

Comment thread rs/moq-cli/src/archive.rs
Comment on lines +231 to +233
changes.push(match pacing {
true => Change::Pacing(name.clone()),
false => Change::Track(name.clone()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject or segment data-only broadcasts

When a valid catalog contains only text, JSON, or binary tracks, every content track is enrolled as non-pacing. moq-mux deliberately produces no timeline for non-pacing tracks alone, even when finishing, so this export reports success but none of those groups, including the catalog, are indexed and the subsequent import serves an empty recording. Give such broadcasts a pacing or explicit-cut policy, or reject them before recording rather than silently losing the content.

AGENTS.md reference: AGENTS.md:L16-L18

Useful? React with 👍 / 👎.

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.

Leaving this. A text, JSON, or binary-only catalog still enrolls those tracks as non-pacing, and moq-mux will not open a timeline without a pacing track, so the export can succeed with nothing indexed. Pacing them would stall the recording whenever a sparse track goes quiet, which is why they are non-pacing. Rejecting the first snapshot would also refuse a broadcast that adds a rendition later. An explicit cut needs a boundary the catalog does not carry. That is a recording-policy choice, not something to guess at while landing this.

(Written by Grok 4.7)

`export archive` enrolled each rendition while the writer was already
committing. A buffered opening rendition could close a segment before the
rest of that snapshot was registered, so those groups never entered the
record. `Control::reserve` withholds commits until the snapshot's tracks
are enrolled.

Also sort the workspace `moq-archive` dependency, which failed `cargo sort`.

Co-Authored-By: Grok 4.7 <noreply@x.ai>

Copy link
Copy Markdown
Collaborator Author

Check failed cargo sort: moq-archive was listed after moq-audio in the workspace manifest. Sorted it.

Also held segment commits across each catalog snapshot (Control::reserve). A rendition already buffered in the opening catalog could close segment 0 before the others enrolled, and those groups never entered the record. an_opening_snapshot_records_every_rendition fails without the hold.

Left the data-only catalog case. Non-pacing tracks alone still publish no timeline, and reject versus pace versus cut is a recording policy choice.

(Written by Grok 4.7)

@kixelated
kixelated merged commit eb55c52 into quest/m1/archive/README Sep 25, 2026
6 of 7 checks passed
@kixelated
kixelated deleted the quest/m1/archive/cli branch September 25, 2026 13:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 966e7fa797

ℹ️ 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".

Comment thread rs/moq-cli/src/archive.rs
Comment on lines +87 to +90
tokio::select! {
result = writer.run() => result.context("recording failed"),
Err(err) = enroll(control, catalog) => Err(err),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wait for enrollment before accepting writer completion

When the source closes while enroll is still awaiting a rendition subscription or .info PUT, such as for a short or already-finished broadcast using a remote store, writer.run() can drain the catalog track and resolve Ok(()) first. This select! then cancels enroll and reports a successful export even though the remaining renditions were never registered or recorded. The new reservation only gates segment commits and does not keep the writer alive, which is fresh evidence beyond the prior snapshot-batching finding; make successful completion conditional on enrollment finishing. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L16-L18

Useful? React with 👍 / 👎.

Comment thread rs/moq-cli/src/archive.rs
Comment on lines +132 to +136
let credentials = std::env::vars_os()
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
.filter(|(key, _)| ["AWS_", "GOOGLE_", "AZURE_"].iter().any(|cloud| key.starts_with(cloud)));
let (store, prefix) =
object_store::parse_url_opts(url, credentials).with_context(|| format!("unsupported archive store {url}"))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Pass only the selected backend's recognized options

When the process has unrelated cloud variables, for example an S3 URL in an environment that also defines GOOGLE_APPLICATION_CREDENTIALS, this iterator passes every AWS_*, GOOGLE_*, and AZURE_* pair to the selected backend. parse_url_opts parses each supplied option as that backend's configuration key, so a foreign or otherwise unrelated prefixed variable is rejected as an unknown key and prevents a valid archive store from opening. Filter to recognized options for the URL's backend instead of forwarding all three namespaces. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread rs/moq-cli/src/archive.rs
Comment on lines +117 to +120
let refresh = async {
loop {
tokio::time::sleep(interval).await;
reader.refresh().await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject a zero follow interval

With --follow 0s, the duration parser accepts zero and tokio::time::sleep(interval) completes every iteration, causing an unbounded loop of object-store LIST requests that consumes CPU and API quota and may throttle the replay. Reject zero before entering follow mode rather than accepting this malformed polling interval. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L16-L18

Useful? React with 👍 / 👎.

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.

1 participant