Skip to content

[02/03] service-backup: publish immutable BackupSets manifest-last and retain complete sets #17

Open

Description

@chrischeng-c4

Problem

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

Several immutable artifacts cannot be considered one successful backup merely
because each upload returned success. A crash can leave a partial set, a stale
latest pointer, or retention that deletes the last good recovery point.
service-backup needs one reader-atomic publication and retention protocol.

Capability Alignment

Capability: Versioned Stateful-Service Backup and Restore.

Capability Gap: no manifest-last publication, complete-set retention, or orphan
partial cleanup across local, GCS, and S3-compatible sinks.

Progress Evidence: failure injection at every stage preserves the previous
successful manifest and never makes a partial set discoverable as latest.

Requirements

  • R1: Store every artifact under immutable, collision-safe set keys and verify
    written size/digest before publication continues.
  • R2: Publish the immutable manifest only after all artifacts verify; update a
    latest-success pointer only after the manifest is durably readable.
  • R3: Make retries idempotent for the same set id/content and fail closed on a
    conflicting reuse of an existing key.
  • R4: Retain complete sets as a unit, always preserve the latest configured
    successful count, and never prune the previous success during a failed run.
  • R5: Detect and safely garbage-collect orphan partial sets after a grace
    boundary without deleting objects referenced by any valid manifest.
  • R6: Implement equivalent observable semantics for file, GCS, and
    S3-compatible backends, including readback verification and bounded errors.

Scope

In Scope

  • Immutable key layout, upload/readback, manifest-last commit, latest pointer,
    set-aware retention, orphan cleanup, retry/idempotence, backend adapters, and
    failure tests.

Out of Scope

  • Restore staging/apply, service-domain barriers, Kubernetes Jobs, incremental
    backups, and cross-region replication.

Acceptance Criteria

  • AC1: Readers observe either the previous complete set or the new complete set,
    never a partial set.
  • AC2: Failure after every artifact, manifest, pointer, or prune stage leaves
    the previous successful set restorable.
  • AC3: Retrying an identical set is safe; conflicting immutable content fails
    without overwriting either version.
  • AC4: Orphan cleanup removes only unreferenced aged partials on all supported
    backends.

Reference Context

Related Specs

Spec Relevance
libs/service-backup/src/lib.rs current sink and retention API
projects/sift/src/storage/archive.rs manifest-last and digest-readback precedent
faberline/axiom#3029 parent lifecycle contract

Spec Plan

Spec ID Action Main Spec Ref
service-backup-set-publication create immutable set and manifest commit protocol
service-backup-set-retention create complete-set retention and orphan cleanup

Verification Inventory

Requirement Gate Oracle Depends On
R1 cargo test -p service-backup --all-features immutable upload and digest-readback cases pass -
R2 cargo test -p service-backup --all-features manifest-last and latest-pointer failure cases preserve prior success R1
R3 cargo test -p service-backup --all-features retry/id conflict cases are deterministic R2
R4 cargo test -p service-backup --all-features retention prunes only complete old sets R2
R5 cargo test -p service-backup --all-features grace-bound orphan cleanup never removes referenced artifacts R4
R6 cargo test -p service-backup --all-features file, GCS, and S3-compatible conformance cases share the oracle R5

Activity

  1. chrischeng-c4 commented on Oct 23, 2026

    @chrischeng-c4
    ContributorAuthor

    Scope of this spec: the first PR of #17 only — R1 (immutable, collision-safe set keys), plus the pure decision logic of R4 and R5 (which sets retention keeps, which partial sets are orphans). The publish order against real sinks (R2, R3), the file/GCS/S3 adapters (R6), and the readback I/O need later specs on this issue, after #16's streaming traits exist. This PR is pure domain code: it adds types and functions, performs no I/O, and changes no existing public item. It does not depend on #16's BackupSetV1: its inputs are plain ids, times and flags.

    Goal

    service-backup gets one place that decides where a backup set lives and what retention may delete, so every backend applies the same rules. Add SetKeyLayout (maps a set id to the immutable keys of its artifacts, its manifest, and the latest pointer), and a pure plan_retention function that, given the sets found in a sink, returns which complete sets to keep, which to delete, and which partial sets are orphans old enough to delete. The rules that matter: the manifest key is the last thing written, so a set with no manifest is partial; retention never deletes the newest keep_latest complete sets, and never deletes a partial set younger than the grace period or one that a complete set references. Later PRs of #17 call these from the publish and prune paths.

    Acceptance

    • New domain files under crates/service-backup/src/domain/ (one main type per file): set_key_layout.rs (SetKeyLayout, SetKeyError), retention_plan.rs (SetInventory, SetState, RetentionPlan, plan_retention). Declare them in src/domain.rs and re-export from src/lib.rs next to the current exports.
    • SetKeyLayout::new(prefix: &str) -> Result<Self, SetKeyError>: prefix is empty or /-separated segments of [A-Za-z0-9._-], none empty, . or .., no leading or trailing /. Keys: artifact_key(set_id, name) = <prefix>/sets/<set_id>/artifacts/<name>, manifest_key(set_id) = <prefix>/sets/<set_id>/manifest.json, latest_key() = <prefix>/latest.json, set_prefix(set_id) = <prefix>/sets/<set_id>/. With an empty prefix the leading <prefix>/ is omitted. set_id uses [A-Za-z0-9._-] without /, non-empty, at most 128 bytes. name uses the artifact-name rule from [01/03] service-backup: define streaming BackupSetV1 manifest and single-object compatibility #16's spec (relative / segments of [A-Za-z0-9._-], no ./.., at most 256 bytes) and may not be manifest.json-shadowing, which the artifacts/ directory already prevents. Invalid input returns SetKeyError (thiserror, one variant per rule), never a panic.
    • SetKeyLayout::parse_set_id(key: &str) -> Option<&str> returns the set id of any key that lies under <prefix>/sets/<id>/ and None for anything else, including latest.json and keys of another prefix. A test shows parse_set_id(artifact_key(id, n)) == Some(id) for ids and names that contain ., -, _ and a nested name a/b.bin.
    • Keys are collision-safe: two different (set_id, name) pairs never give the same key, and no artifact key equals a manifest key or the latest key. A test checks this over a small cross product including names manifest.json and latest.json.
    • SetInventory { set_id: String, state: SetState, newest_object_at: SystemTime } and SetState::{Complete { created_at: SystemTime }, Partial}. Complete means the set's manifest object exists; Partial means objects exist under the set prefix but no manifest.
    • plan_retention(inventory: &[SetInventory], keep_latest: usize, now: SystemTime, orphan_grace: Duration) -> RetentionPlan is pure and deterministic (input order does not change the output; ties on created_at break by set_id). RetentionPlan { keep: Vec<String>, delete_complete: Vec<String>, delete_orphans: Vec<String> }, each sorted by set_id.
    • Rules of plan_retention: the keep_latest newest Complete sets are kept, with keep_latest raised to at least 1 so the plan never deletes the last complete set; older Complete sets go to delete_complete; a Partial set goes to delete_orphans only when now - newest_object_at >= orphan_grace, otherwise it is neither kept nor deleted (left out of all three lists); an empty inventory gives an empty plan; with zero Complete sets nothing in delete_complete is produced.
    • New test module crates/service-backup/tests/it/set_layout.rs, registered in tests/it/main.rs without a feature gate, with one case each for: the key shapes with and without a prefix; each invalid prefix, set id and name; parse_set_id round trip and rejection of latest.json and a foreign prefix; the collision cross product; retention keeps the newest N; keep_latest = 0 still keeps one; a young partial set is untouched; an aged partial set is an orphan; input order does not change the plan; an inventory with only partial sets deletes no complete set.

    Never

    • Do not change BackupSink, SnapshotSource, run_backup_once, LocalFsSink, GcsSink, the S3 sink, fetch_backup_object, or RetentionPolicy. The existing age-based prune stays as it is until a later PR replaces it.
    • Do not do I/O, read clocks or use randomness in the new domain files (std::fs, SystemTime::now, Utc::now); ddd.toml [policy.domain] forbids them. The caller passes now.
    • Do not derive schemars::JsonSchema on the new types, and do not change tests/it/schema_golden.rs or tests/it/wire_golden.rs.
    • Do not add a dependency to crates/service-backup/Cargo.toml.
    • Do not edit README.md, llms.txt, src/api/llm.rs, or docs/ in this PR.
    • Do not touch other crates, ddd.toml, or CI.

    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 #63; 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