prune.rs raw

//! Removing snapshots and sweeping what nothing needs any more, and the
//! machinery that keeps that from taking chunks out from under a backup
//! that is still going to need them.

mod common;

use std::sync::{Arc, atomic::AtomicBool};

use beeping::{
    pipeline::{Outcome, backup},
    prune::prune,
};
use repository::{Coverage, Lock, Repository};

use common::{deterministic_bytes, manifest_map, open_repository, read_back};

/// Backs up `root` into `repository`, suspending at once if asked.
fn run(
    repository: &Arc<Repository>,
    root: &std::path::Path,
    state: &std::path::Path,
    suspend: bool,
) -> Outcome {
    let (events, receiver) = crossbeam_channel::unbounded();
    drop(receiver);

    backup::run(
        repository.clone(),
        backup::BackupOptions::new(root, state),
        events,
        Arc::new(AtomicBool::new(suspend)),
    )
    .unwrap()
}

fn tree(root: &std::path::Path, files: std::ops::Range<u64>) {
    std::fs::create_dir_all(root).unwrap();

    for index in files {
        std::fs::write(
            root.join(format!("file-{index}.dat")),
            deterministic_bytes(20_000, index),
        )
        .unwrap();
    }
}

/// Pruning takes away the chunks nothing needs any more, and nothing
/// else: what a surviving snapshot shares with the removed one stays.
#[test]
fn pruning_sweeps_only_what_nothing_needs() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("tree");
    let repository = open_repository(dir.path());

    // One snapshot, then a second with some of the same content and
    // some new
    tree(&root, 0..30);
    let Outcome::Completed(first) = run(&repository, &root, &dir.path().join("state-1"), false)
    else {
        panic!("expected completion");
    };

    tree(&root, 30..50);
    for index in 0..10 {
        std::fs::remove_file(root.join(format!("file-{index}.dat"))).unwrap();
    }

    let Outcome::Completed(second) = run(&repository, &root, &dir.path().join("state-2"), false)
    else {
        panic!("expected completion");
    };

    let before = repository.chunks().unwrap().len();
    let survives = manifest_map(&repository, second);

    let outcome = prune(&repository, &[first], false, false, |_| {}).unwrap();

    assert_eq!(outcome.removed, vec![first]);
    assert_eq!(outcome.kept, 1);
    assert!(
        outcome.swept > 0,
        "the removed snapshot had content of its own"
    );
    assert!(
        outcome.retained < before,
        "the repository should have shrunk"
    );
    assert_eq!(repository.chunks().unwrap().len(), outcome.retained);

    // The removed snapshot is gone; the surviving one is untouched, and
    // every byte of it still reads back
    assert!(matches!(
        repository.load_snapshot(first),
        Err(repository::Error::MissingSnapshot(_))
    ));

    let after = manifest_map(&repository, second);
    assert_eq!(after.len(), survives.len());

    for (path, entry) in &after {
        if let repository::EntryKind::File { .. } = entry.kind {
            assert_eq!(
                read_back(&repository, entry),
                std::fs::read(root.join(path)).unwrap(),
                "{path:?} did not survive the prune intact"
            );
        }
    }
}

/// A backup that has stored chunks no snapshot names yet must not have
/// them swept away underneath it.
#[test]
fn pruning_refuses_while_a_backup_holds_the_repository() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("tree");
    let repository = open_repository(dir.path());

    tree(&root, 0..10);
    let Outcome::Completed(snapshot) = run(&repository, &root, &dir.path().join("state"), false)
    else {
        panic!("expected completion");
    };

    // A run that was killed leaves its lock behind, which is exactly the
    // state that should stop a prune
    let lock = Lock::new(root.clone(), "some-other-host");
    repository.take_lock(&lock).unwrap();

    let refused = prune(&repository, &[snapshot], false, false, |_| {}).unwrap_err();
    let message = format!("{refused}");

    assert!(message.contains("some-other-host"), "{message}");
    assert!(message.contains("--break-lock"), "{message}");
    assert!(
        repository.load_snapshot(snapshot).is_ok(),
        "a refused prune must not have removed anything"
    );

    // Breaking the lock is the documented way through
    prune(&repository, &[snapshot], false, true, |_| {}).unwrap();
    assert!(matches!(
        repository.load_snapshot(snapshot),
        Err(repository::Error::MissingSnapshot(_))
    ));

    // A dry run says what it would do and leaves the lock's owner alone
    repository.take_lock(&lock).unwrap();
    assert!(prune(&repository, &[], true, false, |_| {}).is_err());
}

/// The lock is held for exactly as long as the run has references no
/// snapshot names: dropped when it finishes, and dropped when it
/// suspends, because suspending publishes them.
#[test]
fn a_backup_holds_the_repository_only_while_it_owes_it_something() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("tree");
    let repository = open_repository(dir.path());

    tree(&root, 0..40);

    let state = dir.path().join("state");
    assert!(matches!(
        run(&repository, &root, &state, true),
        Outcome::Suspended { .. }
    ));

    assert!(
        repository.locks().unwrap().is_empty(),
        "a suspended run has published what it recorded, so it holds nothing"
    );

    assert!(matches!(
        run(&repository, &root, &state, false),
        Outcome::Completed(_)
    ));

    assert!(
        repository.locks().unwrap().is_empty(),
        "a finished run holds nothing either"
    );
}

/// A prune between one session of a backup and the next moves the
/// repository's epoch, and the resumed run puts back whatever it had
/// recorded rather than assuming those chunks are still there.
#[test]
fn a_prune_between_sessions_makes_the_run_re_store_what_it_recorded() {
    let dir = tempfile::tempdir().unwrap();
    let repository = open_repository(dir.path());

    // Something else in the repository, for the prune to remove
    let other = dir.path().join("other");
    tree(&other, 100..120);

    let Outcome::Completed(disposable) =
        run(&repository, &other, &dir.path().join("state-o"), false)
    else {
        panic!("expected completion");
    };

    // The backup we care about, suspended part-way
    let root = dir.path().join("tree");
    tree(&root, 0..60);

    let state = dir.path().join("state");
    assert!(matches!(
        run(&repository, &root, &state, true),
        Outcome::Suspended { .. }
    ));

    let epoch_before = repository.epoch().unwrap();
    prune(&repository, &[disposable], false, false, |_| {}).unwrap();
    assert_ne!(
        repository.epoch().unwrap(),
        epoch_before,
        "a prune must move the epoch, or no cache would know to doubt itself"
    );

    // Resuming notices and finishes correctly regardless
    let Outcome::Completed(snapshot) = run(&repository, &root, &state, false) else {
        panic!("expected completion");
    };

    let map = manifest_map(&repository, snapshot);

    for index in 0..60 {
        let path = std::path::PathBuf::from(format!("file-{index}.dat"));
        let entry = map.get(&path).unwrap_or_else(|| panic!("{path:?} missing"));

        assert_eq!(
            read_back(&repository, entry),
            std::fs::read(root.join(&path)).unwrap(),
            "{path:?} did not survive the prune intact"
        );
    }
}

/// A suspended backup's partial snapshot is what keeps its recorded work
/// findable. Pruning it leaves the run unable to finish, and it must say
/// so rather than producing a snapshot full of dangling references.
#[test]
fn a_pruned_partial_snapshot_stops_its_backup_rather_than_finishing_it_wrong() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("tree");
    let repository = open_repository(dir.path());

    tree(&root, 0..60);

    let state = dir.path().join("state");
    let Outcome::Suspended { restorable } = run(&repository, &root, &state, true) else {
        panic!("expected suspension");
    };

    let partial = restorable.expect("suspending publishes what it has stored");
    assert_eq!(
        repository.load_snapshot(partial).unwrap().coverage,
        Coverage::Partial
    );

    // Pruning it is allowed — it is a snapshot like any other — but it
    // takes the resumed run's recorded work with it
    let mut warnings = Vec::new();
    prune(&repository, &[partial], false, false, |note| {
        warnings.push(note.to_string())
    })
    .unwrap();

    assert!(
        warnings
            .iter()
            .any(|note| note.contains("partial snapshot")),
        "the prune should have said what it was removing: {warnings:?}"
    );

    let (events, receiver) = crossbeam_channel::unbounded();
    drop(receiver);

    let failed = backup::run(
        repository.clone(),
        backup::BackupOptions::new(&root, &state),
        events,
        Arc::new(AtomicBool::new(false)),
    )
    .unwrap_err();

    assert!(
        matches!(failed, beeping::pipeline::Error::RecordedWorkPruned { .. }),
        "expected a refusal to finish, got {failed:?}"
    );

    // And it says what to do about it
    let message = format!("{failed}");
    assert!(message.contains("run the backup again"), "{message}");
}