Skip to content

feat(archive): resume a recording on restart - #4114

Merged
kixelated merged 2 commits into
quest/m1/archive/READMEfrom
quest/m1/archive/recovery
Sep 25, 2026
Merged

kixelated merged 2 commits into
quest/m1/archive/READMEfrom
quest/m1/archive/recovery

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Problem

moq_archive::Writer::new refused any prefix that already held a timeline, so a restarted recorder could not continue its own recording. A crash also left DVR orphans behind: expired objects whose deletion was interrupted, and uploads whose segment never committed.

Approach

Writer::new now recovers the prefix instead of refusing it (new private rs/moq-archive/src/recover.rs):

  • One listing of the whole prefix finds the timeline's segments/ and every track's groups/. The segment IDs must be contiguous.
  • It walks back from the newest timeline object to a checkpoint that restates the retained window, then replays forward. An archive only needs the newest checkpoint. A DVR needs the whole window, which can be longer than the 256 records one checkpoint restates.
  • The timeline resumes at the next segment through the new timeline::Producer::resume. Stored timeline group sequences continue past the last stored one, so a reader's replayed timeline track keeps increasing.
  • A DVR schedules deletion of every listed group object that no retained record references, one grace period after recovery. .info and timeline objects are never candidates.
  • Any listing, GET, or decode failure fails Writer::new before anything is scheduled, so a failed or incomplete recovery deletes nothing.

Overlap decision: each track's floor is the largest group stored under it, orphans included. Groups at or below the floor are refused through the existing duplicate path. A source replaying its cache after a restart just skips what the recording already holds. A source whose group sequences restarted can't continue the recording and needs a new prefix. Alternatives considered:

  • Fail the recording on the first group below the floor. Rejected because a legitimate cache replay after a restart looks the same.
  • Start a new recording automatically. Rejected because the prefix is the application's choice, not the writer's.

Tests

Regression tests are in writer.rs, run against the in-memory store:

  • Resume continues the segment numbering and the stored timeline group numbering, and skips replayed groups.
  • A DVR restart deletes an interrupted expiration, an uncommitted upload, and an unreferenced track's object, while keeping .info and timeline objects.
  • A listing that fails partway, and a missing timeline object, both fail recovery and delete nothing.
  • A DVR window of more than 256 records is recovered in full.

Unit tests also cover the moq-json and moq-mux seeding.

Impact

Public API (all additive except the removed archive error variant):

  • moq_json::window::Checkpoint<T> { range: Range<u64>, records: Vec<T> }, Encoder::resume(config, &Checkpoint<T>) -> Result<Self>, Producer::resume(track, config, &Checkpoint<T>) -> Result<Self>.
  • moq_mux::timeline::Producer::resume(broadcast, config, &Checkpoint<Record>) -> Result<Self>. A timeline's window index is its segment number, so the next record is segment range.end. A mismatched record fails with the new moq_mux::Error::TimelineCheckpoint(index).
  • moq_archive::Error::Occupied is removed (0.0.x crate). Writer::new now resumes instead of returning it.

Wire: none. The draft's Retention and Bootstrap and Recovery sections already specify this behavior.

Alternatives

  • Checkpoint mirrors the window header (offset/start/records), not a separate range + suffix. I kept range to match Encoder::range() and window().
  • Seeding the moq-json Producer through a pristine-only seed() method instead of a constructor. Rejected because the constructor makes seeding a used window unrepresentable.

Follow-ups

  • Recovery lists the whole prefix, which costs O(stored objects) for a long unbounded archive. Listing each track's groups/ from a lexical offset (as Query::groups_from does), plus the timeline keys, would make it proportional to what changed.
  • The resumed segmenter doesn't reject a source whose timestamps regress below the recovered timeline end.

Closes nothing. Deletes quest/m1/archive/recovery.md, which unblocks quest/m1/archive/dvr.md.

🤖 Generated with Claude Code

(written by Claude Opus 5.5)

@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-25T14:07:51.413441Z 1aad278 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. Restarting a recorder used to hit Occupied and leave DVR orphans from interrupted expiration or uploads. This recovers the retained timeline, continues segment and stored timeline-group numbering, and cleans unreferenced group objects after the grace period—matching the recovery quest and unblocking DVR.

Worth the complexity? Yes. The hard parts are isolated where they belong: moq_json::window::Checkpoint + Encoder/Producer::resume, timeline::Producer::resume, and private recover. Fail-closed recovery (list/GET/decode failure deletes nothing) is the right default. The overlap rule (floor = largest stored group, orphans included; refuse ≤ floor) is deliberate and tested; cache replay after restart works, and a source that reset sequences needs a new prefix.

Different approach? The rejected alternatives (fail on first group below floor; auto-start a new recording; seed via a pristine-only seed(); mirror the window header shape instead of range) are weaker for this API. Keeping range aligned with Encoder::range() is the better public shape. The two follow-ups in the description (offset listing for long archives; reject regressing timestamps on resume) are real but not merge blockers.

Notes (non-blocking):

  • Orphan detection uses the same first.start..=last.end bounding key as segment expiration, so it stays consistent with how put_groups names objects.
  • A DVR prefix with media groups but no timeline segments treats every group as an orphan; that matches “failed recording leftover” cleanup, but is worth remembering if operators ever strip timeline objects by hand.

Tests cover resume + sequence continuity, DVR orphan cleanup, failed recovery deleting nothing, and windows longer than one checkpoint.

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: 6362dc5112

ℹ️ 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 on lines +55 to +56
let (checkpoint, sequence) = replay(store, timeline, first..=last, complete).await?;
(Some(checkpoint), sequence)

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 Validate the recovered cursor against the last segment

When the segment keys are contiguous but the newest object's decoded window ends anywhere other than last + 1, recovery still succeeds as long as each retained record matches its window index. For example, if segments/5 is damaged but validly restates only records 0 through 4, resume sets the next segment to 5; the next commit uploads its media objects and then collides with the existing segments/5, leaving new orphans and stopping the writer. Check that checkpoint.range.end == last + 1 here so this malformed prefix is rejected before accepting new work.

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

Useful? React with 👍 / 👎.

kixelated and others added 2 commits September 25, 2026 06:59
Writer::new now recovers a prefix that already holds a recording instead of
refusing it: it replays the retained timeline from a checkpoint, continues at
the next segment through the new timeline::Producer::resume, refuses groups
at or below each track's largest stored group, and a DVR deletes unreferenced
group objects one grace period after a successful recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the quest/m1/archive/recovery branch from 6362dc5 to 1aad278 Compare September 25, 2026 14:04

Copy link
Copy Markdown
Collaborator Author

Rebased onto quest/m1/archive/README after #4113.

The writer conflict kept both sides: enrollment still starts from each track's recovered floor, and the malformed-group timestamp check stays. Control::reserve / Reservation is unchanged, so a catalog snapshot still enrolls under that hold.

Also rejected a recovered window that does not end at the next segment after the newest timeline object. Resuming there would write the next segment on top of one that already exists. Covered by a_timeline_that_does_not_end_at_the_next_segment_fails_recovery.

(Written by Grok 4.7)

@kixelated
kixelated merged commit fc61e33 into quest/m1/archive/README Sep 25, 2026
2 of 4 checks passed
@kixelated
kixelated deleted the quest/m1/archive/recovery branch September 25, 2026 14:04

@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: 1aad278834

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

}

let last = objects.back().and_then(|object| object.groups.last());
let sequence = last.map_or(Ok(0), |group| group.sequence.checked_add(1).ok_or(Error::Overflow))?;

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 non-increasing timeline group sequences

When retained timeline objects have valid checkpoint payloads but a later object's group sequence is lower than an earlier object's, recovery accepts them and derives the resumed sequence solely from the newest object. The resumed writer can therefore reuse an existing group ID; Reader::replay treats that collision as moq_net::Error::Duplicate and skips the newly committed timeline group (rs/moq-archive/src/reader/mod.rs:200-203), potentially hiding the final resumed segments until a later checkpoint appears. Validate group sequences across the replayed objects before deriving the next value. (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