#![allow(clippy::unwrap_used)]
use std::collections::BTreeSet;
use std::path::PathBuf;
use super::*;
use crate::manifest::FileEntry;
use crate::manifest::Manifest;
use pretty_assertions::assert_eq;
struct Store {
dir: tempfile::TempDir,
refs: RefStore,
turns: TurnIndex,
manifests: ManifestStore,
}
impl Store {
fn log_path(&self, thread_id: &str) -> PathBuf {
self.dir
.path()
.join("refs")
.join(format!("{thread_id}.json"))
}
fn restore_path(&self, thread_id: &str) -> PathBuf {
self.dir
.path()
.join("restores")
.join(format!("{thread_id}.json"))
}
}
fn store() -> Store {
let dir = tempfile::tempdir().unwrap();
Store {
refs: RefStore::open(dir.path().join("refs")).unwrap(),
turns: TurnIndex::open(dir.path()).unwrap(),
manifests: ManifestStore::open(dir.path().join("manifests")).unwrap(),
dir,
}
}
fn manifest(s: &Store, hash: &str) -> String {
let mut m = Manifest::default();
m.entries.insert(
"/f".into(),
FileEntry {
mode: Some(0o644),
size: 1,
mtime_secs: 1,
mtime_nanos: 0,
hash: hash.to_string(),
},
);
s.manifests.save(&m).unwrap()
}
fn age_out(path: &Path) {
let when = SystemTime::now() - GC_GRACE - Duration::from_secs(60);
let f = fs::File::options().write(true).open(path).unwrap();
f.set_times(fs::FileTimes::new().set_modified(when))
.unwrap();
}
fn ids(s: &Store) -> BTreeSet<String> {
s.manifests.ids().unwrap().into_iter().collect()
}
#[test]
fn a_prune_touches_nothing_the_doomed_sessions_did_not_name() {
let s = store();
let doomed_id = manifest(&s, "hash-doomed");
let bystander_id = manifest(&s, "hash-bystander");
s.refs
.append("doomed", "turn-doomed".into(), doomed_id.clone())
.unwrap();
s.turns.set_turn("turn-doomed", &doomed_id).unwrap();
s.refs
.append("bystander", "turn-live".into(), bystander_id.clone())
.unwrap();
s.turns.set_turn("turn-live", &bystander_id).unwrap();
let doomed_turns = BTreeSet::from([crate::refs::turn_file_name("turn-doomed")]);
let doomed_manifests = BTreeSet::from([doomed_id.clone()]);
s.refs.remove("doomed").unwrap();
let first = prune_sessions(
&s.refs,
&s.turns,
&s.manifests,
&doomed_turns,
&doomed_manifests,
)
.unwrap();
assert_eq!(
s.turns.manifest_for_turn("turn-doomed").unwrap(),
None,
"unreachable immediately"
);
assert_eq!(first.manifests_removed, 0, "the manifest is too young");
assert!(s.manifests.load(&doomed_id).is_ok());
age_out(&s.manifests.path_for(&doomed_id).unwrap());
age_out(&s.manifests.path_for(&bystander_id).unwrap());
let stats = prune_sessions(
&s.refs,
&s.turns,
&s.manifests,
&doomed_turns,
&doomed_manifests,
)
.unwrap();
assert_eq!(stats.manifests_removed, 1);
assert_eq!(ids(&s), BTreeSet::from([bystander_id.clone()]));
assert_eq!(
s.turns.manifest_for_turn("turn-live").unwrap(),
Some(bystander_id),
"the bystander's turn entry is untouched"
);
assert_eq!(s.turns.manifest_for_turn("turn-doomed").unwrap(), None);
}
#[test]
fn a_prune_keeps_what_a_survivor_still_names() {
let s = store();
let shared = manifest(&s, "hash-shared");
s.refs
.append("doomed", "turn-a".into(), shared.clone())
.unwrap();
s.refs
.append("survivor", "turn-b".into(), shared.clone())
.unwrap();
s.refs.remove("doomed").unwrap();
let stats = prune_sessions(
&s.refs,
&s.turns,
&s.manifests,
&BTreeSet::from([crate::refs::turn_file_name("turn-a")]),
&BTreeSet::from([shared.clone()]),
)
.unwrap();
assert_eq!((stats.manifests_kept, stats.manifests_removed), (1, 0));
assert!(s.manifests.load(&shared).is_ok());
}
#[test]
fn an_unreadable_log_defers_reclamation_rather_than_guessing() {
let s = store();
let doomed_id = manifest(&s, "hash-doomed");
s.refs
.append("doomed", "turn-a".into(), doomed_id.clone())
.unwrap();
s.refs
.append("other", "turn-b".into(), manifest(&s, "hash-other"))
.unwrap();
s.refs.remove("doomed").unwrap();
fs::write(s.log_path("corrupt"), b"{\"version\":1,\"entr").unwrap();
let before = ids(&s);
let stats = prune_sessions(
&s.refs,
&s.turns,
&s.manifests,
&BTreeSet::from([crate::refs::turn_file_name("turn-a")]),
&BTreeSet::from([doomed_id]),
)
.unwrap();
assert_eq!(stats.manifests_removed, 0);
assert_eq!(
ids(&s),
before,
"nothing is removed on an incomplete answer"
);
}
#[test]
fn collection_reclaims_the_orphan_a_prune_cannot_see() {
let s = store();
let live = manifest(&s, "hash-live");
let orphan = manifest(&s, "hash-orphan");
s.refs.append("t", "turn-a".into(), live.clone()).unwrap();
s.turns.set_turn("turn-a", &live).unwrap();
let stats = collect_partition(&s.refs, &s.turns, &s.manifests).unwrap();
assert_eq!(stats.manifests_removed, 0);
age_out(&s.manifests.path_for(&orphan).unwrap());
age_out(&s.manifests.path_for(&live).unwrap());
let stats = collect_partition(&s.refs, &s.turns, &s.manifests).unwrap();
assert_eq!((stats.manifests_kept, stats.manifests_removed), (1, 1));
assert_eq!(ids(&s), BTreeSet::from([live]));
}
#[test]
fn a_partition_sweep_reports_no_content_reclaimed() {
let s = store();
let id = manifest(&s, "hash-1");
s.refs.append("t", "turn-a".into(), id).unwrap();
let swept = collect_partition(&s.refs, &s.turns, &s.manifests).unwrap();
let pruned = prune_sessions(
&s.refs,
&s.turns,
&s.manifests,
&BTreeSet::new(),
&BTreeSet::new(),
)
.unwrap();
assert_eq!((swept.blobs_kept, swept.blobs_removed), (0, 0));
assert_eq!((pruned.blobs_kept, pruned.blobs_removed), (0, 0));
}
#[test]
fn collection_drops_an_undo_record_whose_session_is_gone() {
let s = store();
let target = manifest(&s, "hash-target");
let safety = manifest(&s, "hash-safety");
s.turns
.push_restore(
"vanished",
crate::refs::RestoreRecord {
target_manifest_id: target.clone(),
safety_manifest_id: safety.clone(),
},
)
.unwrap();
assert!(s.turns.orphan_restore_logs(&s.refs).unwrap().is_empty());
for p in [
s.restore_path("vanished"),
s.manifests.path_for(&target).unwrap(),
s.manifests.path_for(&safety).unwrap(),
] {
age_out(&p);
}
let stats = collect_partition(&s.refs, &s.turns, &s.manifests).unwrap();
assert_eq!(
stats.manifests_removed, 2,
"the record went, and took its two manifests with it in the same pass"
);
assert_eq!(ids(&s), BTreeSet::new());
}
#[test]
fn residue_is_reclaimed_once_it_is_settled() {
let dir = tempfile::tempdir().unwrap();
let stray = dir.path().join("half-written.tmp");
let real = dir.path().join("record.json");
fs::write(&stray, b"partial").unwrap();
fs::write(&real, b"{}").unwrap();
assert_eq!(sweep_residue(dir.path()), 0, "fresh residue may be in use");
age_out(&stray);
age_out(&real);
assert_eq!(sweep_residue(dir.path()), 1);
assert!(!stray.exists());
assert!(real.exists(), "a real record is not residue");
}