Skip to content

[03/03] service-backup: stage and restore BackupSets idempotently with service-owned validation #18

Open

Description

@chrischeng-c4

Problem

Parent epic: faberline/axiom#3029. Execution position: 03 of 03.

A valid published BackupSet still needs a bounded, restart-safe path from
object storage to a service-owned restore. Fetching everything into memory or
partially applying before compatibility checks can make a fresh instance ready
with incomplete or incompatible state.

Capability Alignment

Capability: Versioned Stateful-Service Backup and Restore.

Capability Gap: no generic set discovery, validation, disk staging, resume, or
service-owned compatibility/apply boundary.

Progress Evidence: staged restore fault tests resume without reapplying
completed artifacts and cannot signal readiness before complete validation and
service acknowledgement.

Requirements

  • R1: Resolve an explicit manifest or latest-success pointer, fetch the
    immutable manifest, and validate version, inventory, size, and digest before
    any service apply begins.
  • R2: Stream artifacts to a bounded local staging directory using atomic
    per-artifact completion markers; never require the full set in memory.
  • R3: Persist restore progress so interruption resumes verified artifacts and
    never confuses a partial file with a completed one.
  • R4: Expose a service adapter that validates domain metadata and applies the
    complete staged set; service-backup does not know shard, membership, index,
    queue, or workflow semantics.
  • R5: Fail closed on missing, duplicate, corrupt, incompatible, or changed
    objects and keep the consumer unready until the service reports success.
  • R6: Define idempotence for replaying the same set and reject an unsafe attempt
    to apply a different set over an in-progress or committed restore.

Scope

In Scope

  • Manifest resolution, download/validation, staging layout, restart progress,
    cleanup, service adapter boundary, readiness outcome, and fault tests.

Out of Scope

  • Service-domain restore implementation, Raft membership rebinding,
    Kubernetes empty-PVC orchestration, incremental restore, and online rollback.

Acceptance Criteria

  • AC1: Large multi-artifact sets restore with bounded memory and verified
    on-disk staging.
  • AC2: Crash after every download/verify/apply boundary resumes exactly once or
    remains fail-closed and unready.
  • AC3: Corrupt, incompatible, missing, duplicate, or replaced artifacts are
    rejected before service apply.
  • AC4: Two example adapters with different metadata prove the shared layer
    contains no service-domain assumptions.

Reference Context

Related Specs

Spec Relevance
libs/service-backup/src/lib.rs current source contract
libs/storage-durable atomic local staging primitives
faberline/axiom#3029 parent lifecycle contract

Spec Plan

Spec ID Action Main Spec Ref
service-backup-set-restore create staging, resume, and adapter contract

Verification Inventory

Requirement Gate Oracle Depends On
R1 cargo test -p service-backup --all-features explicit/latest manifest validation cases pass -
R2 cargo test -p service-backup --all-features large-set bounded-memory staging cases pass R1
R3 cargo test -p service-backup --all-features crash-resume markers never accept partial files R2
R4 cargo test -p service-backup --all-features multiple domain adapters compile and receive only complete staged sets R3
R5 cargo test -p service-backup --all-features corruption/incompatibility keeps readiness false and skips apply R1
R6 cargo test -p service-backup --all-features same-set replay is idempotent and conflicting-set replay is rejected R4

Activity

  1. chrischeng-c4 commented on Oct 23, 2026

    @chrischeng-c4
    ContributorAuthor

    Scope of this spec: the first PR of #18 only — R3 and R6 as pure domain logic (the persisted restore progress ledger and its transitions), plus the part of AC2 that a pure state machine can prove. Manifest resolution (R1), disk staging and download (R2), the service adapter (R4), readiness wiring (R5) and AC1/AC3/AC4 need later specs on this issue, after #16's streaming traits and BackupSetV1 are on main. This PR adds types only, performs no I/O, and changes no existing public item. It does not depend on #16: its inputs are plain strings and integers.

    Goal

    A restore that is interrupted at any step must resume without re-applying finished work and without mistaking a half-written file for a finished one. Add one pure domain type, RestoreLedger, that records which set is being restored and the state of each artifact, enforces the legal transitions, serializes to one canonical JSON document for the later staging code to persist, and refuses to start a different set over an in-progress or committed restore. Later PRs of #18 persist it atomically (through storage-durable) and drive it from the download, verify and apply steps.

    Acceptance

    • New domain files under crates/service-backup/src/domain/ (one main type per file): restore_ledger.rs (RestoreLedger, ArtifactStage, RestorePhase), restore_ledger_error.rs (RestoreLedgerError, a thiserror enum with one variant per failure below). Declare them in src/domain.rs and re-export from src/lib.rs next to the current exports.
    • RestoreLedger::begin(set_id: &str, manifest_digest: &str, artifacts: &[(String, u64)]) -> Result<Self, RestoreLedgerError>: set_id is non-empty, at most 128 bytes, of [A-Za-z0-9._-]; manifest_digest is "sha256:" plus 64 lowercase hex chars; artifacts is a non-empty list of (name, size_bytes) with unique non-empty names, at most 1024 entries. Every artifact starts at ArtifactStage::Pending, the phase at RestorePhase::Staging.
    • ArtifactStage is Pending, Verified, Applied; RestorePhase is Staging, Applying, Committed. Transitions, each a method returning Result<(), RestoreLedgerError>: mark_verified(name) (Pending → Verified; repeating it on Verified or Applied is Ok and changes nothing), begin_apply() (Staging → Applying, only when every artifact is Verified; repeating it while Applying is Ok), mark_applied(name) (only in Applying, Verified → Applied; repeating on Applied is Ok), commit() (Applying → Committed, only when every artifact is Applied; repeating on Committed is Ok). An unknown artifact name, a skipped step, or a transition from the wrong phase returns the matching error variant and leaves the ledger unchanged.
    • RestoreLedger::resume(&self, set_id: &str, manifest_digest: &str) -> Result<ResumePoint, RestoreLedgerError> with ResumePoint::{Fresh, Continue, AlreadyCommitted}: the same set_id and manifest_digest give AlreadyCommitted when Committed, else Continue; a different set_id, or the same set_id with another digest (a replaced manifest), returns RestoreLedgerError::ConflictingSet carrying both ids, and never changes the ledger. (Fresh is returned by a free function RestoreLedger::resume_or_begin(existing: Option<Self>, set_id, manifest_digest, artifacts) -> Result<(Self, ResumePoint), RestoreLedgerError> when existing is None.)
    • pending(&self) -> Vec<&str> (artifacts still Pending, in manifest order), unapplied(&self) -> Vec<&str> (artifacts not yet Applied, in manifest order), phase(), set_id(), manifest_digest() accessors.
    • to_json(&self) -> Vec<u8> writes compact JSON with a "version": 1 field, artifacts in their original order; RestoreLedger::from_json(bytes: &[u8]) -> Result<Self, RestoreLedgerError> refuses input over 1 MiB before parsing, unknown fields, unsupported versions, and any document that violates the invariants of begin or of the phase (for example Committed with an artifact not Applied, or Staging with an Applied artifact). Serde uses camelCase and deny_unknown_fields. from_json(to_json(x)) round-trips.
    • Crash model test: for every prefix of the full transition sequence of a 3-artifact ledger, serialize, reload, resume with the same ids, replay the whole sequence (idempotent repeats allowed), and reach Committed with each artifact applied exactly once. "Applied exactly once" is proved by counting the mark_applied calls that changed state.
    • No new dependency: use serde and serde_json as already declared in crates/service-backup/Cargo.toml.
    • New test module crates/service-backup/tests/it/restore_ledger.rs, registered in tests/it/main.rs without a feature gate, with one case each for: the valid full path; each invalid begin input; each illegal transition; idempotent repeats; ConflictingSet for a different set and for a replaced manifest; AlreadyCommitted replay; the JSON round trip; each rejected JSON document (oversized, unknown field, version 2, inconsistent phase); and the crash-prefix test above.

    Never

    Gate

    cargo test --locked -p service-backup
    cargo test --locked -p service-backup --all-features
    cargo clippy --locked -p service-backup --all-features --all-targets -- -D warnings
    cargo fmt -p service-backup -- --check
  2. added
    todoReady for the work step of the issue loop
    doingThe work step of the issue loop is on it
    and removed
    todoReady for the work step of the issue loop
    on Oct 24, 2026
  3. chrischeng-c4 commented on Oct 24, 2026

    @chrischeng-c4
    ContributorAuthor

    Work submitted in #64; waiting for required CI on the current head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions