use std::{
path::Path,
time::Duration,
};
use miette::Result;
use super::{DropArgs, HoldAction, HoldArgs, ReattachArgs};
use crate::actions::{
Context,
canopy::{
backup::{
BackupArgs,
hold::{CaptureState, HoldRecord, capture_state, list, load, records_dir, release},
},
restore::RestoreArgs,
},
};
const FROZEN: &[u8] = b"the value as it stood when the capture froze";
const LIVE: &[u8] = b"the value written after the capture froze";
const MARKER: &str = "bestool-hold-e2e.marker";
const BALLAST: &str = "bestool-hold-e2e.ballast";
const BALLAST_BYTES: usize = 64 * 1024 * 1024;
const RECLAIM_WITHIN: Duration = Duration::from_secs(120);
trait Backend {
fn backup_type(&self) -> &str;
fn backups_dir(&self) -> &Path;
fn data_dir(&self) -> &Path;
fn expected_backend(&self) -> &'static str;
fn exposes_separately(&self) -> bool {
true
}
async fn cluster_is_up(&self) -> bool;
async fn capture_present(&self, record: &HoldRecord) -> bool;
fn needs_ballast(&self) -> bool {
false
}
async fn capture_exclusive_bytes(&self, record: &HoldRecord) -> Option<u64> {
let _ = record;
None
}
async fn store_in_use(&self) -> Option<f64> {
None
}
fn released_margin(&self) -> f64 {
0.0
}
async fn quiesce_store(&self) {}
async fn settle_store(&self) {}
async fn store_diagnostics(&self) -> String {
String::new()
}
}
async fn lifecycle<B: Backend>(backend: &B) {
let data_dir = backend.data_dir().to_path_buf();
let backup_type = backend.backup_type().to_owned();
let needs_ballast = backend.needs_ballast();
let ballast_path = data_dir.join(BALLAST);
let mut pin = |seed: u64| {
if needs_ballast {
write_ballast(&ballast_path, seed);
}
};
write(&data_dir.join(MARKER), FROZEN);
pin(1);
backup(&backup_type, backend.backups_dir())
.await
.expect("taking a capture-only hold");
let held = sole_hold(&backup_type).await;
assert_eq!(
held.capture.backend(),
backend.expected_backend(),
"hold {} was taken with the {} backend; the intended one must have failed and \
fallen back",
held.id,
held.capture.backend(),
);
assert!(!held.uploaded, "a capture-only hold reported itself uploaded");
write(&data_dir.join(MARKER), LIVE);
pin(2);
hold(HoldAction::List).await.expect("listing held captures");
assert_state(&held, CaptureState::Present, "just after it was taken").await;
assert_marker(
&held.source,
FROZEN,
"the held capture at the path its record names",
);
restore(&backup_type, backend.backups_dir(), &held.id)
.await
.expect("restoring from the held capture");
assert_marker(&data_dir, FROZEN, "the restored cluster");
assert!(
backend.cluster_is_up().await,
"the cluster is not accepting connections after a restore from hold {}",
held.id,
);
let after = load(&held.id)
.await
.expect("the hold survived the restore that read it");
assert_state(&after, CaptureState::Present, "after a restore from it").await;
if backend.exposes_separately() {
hold(HoldAction::Reattach(ReattachArgs { id: held.id.clone() }))
.await
.expect("reattaching a healthy hold");
assert_state(&after, CaptureState::Present, "after reattaching it healthy").await;
assert_marker(&after.source, FROZEN, "the held capture after a reattach");
} else {
let err = hold(HoldAction::Reattach(ReattachArgs { id: held.id.clone() }))
.await
.expect_err("a capture with no separate exposure cannot be reattached");
assert!(err.to_string().contains("not supported"), "{err}");
}
pin(3);
assert!(
backend.capture_present(&held).await,
"the {} probe cannot see hold {}'s capture while it is still held, so it \
proves nothing about the capture being gone after the drop",
held.capture.backend(),
held.id,
);
backend.quiesce_store().await;
let exclusive = backend.capture_exclusive_bytes(&held).await;
let before_drop = match exclusive {
Some(_) => None,
None => backend.store_in_use().await,
};
assert!(
!needs_ballast || exclusive.is_some() || before_drop.is_some(),
"the {} backend pins ballast for a release assertion, but can neither account \
for the capture's own storage nor read the store it comes out of, so nothing \
would have checked that dropping the hold returned anything",
backend.expected_backend(),
);
if let Some(bytes) = exclusive {
assert!(
bytes as f64 >= backend.released_margin(),
"hold {} holds only {bytes} bytes that nothing else does, short of the {} a \
released capture has to account for, so dropping it would return too little \
to tell from nothing.\n{}",
held.id,
backend.released_margin(),
backend.store_diagnostics().await,
);
}
hold(HoldAction::Drop(DropArgs { id: held.id.clone() }))
.await
.expect("dropping the hold");
backend.settle_store().await;
assert!(
load(&held.id).await.is_err(),
"hold {} still has a record after being dropped",
held.id,
);
assert!(
!backend.capture_present(&held).await,
"dropping hold {} removed its record but left the {} capture behind it on the storage",
held.id,
held.capture.backend(),
);
if let Some(before) = before_drop
&& let Err(seen) = store_fell_by(backend, before, backend.released_margin()).await
{
panic!(
"dropping hold {} did not return the capture's space: the store was still \
within {} of {before} after {}s.\n what the store read: {seen}\n{}",
held.id,
backend.released_margin(),
RECLAIM_WITHIN.as_secs(),
backend.store_diagnostics().await,
);
}
}
async fn assert_state(record: &HoldRecord, want: CaptureState, when: &str) {
let got = capture_state(record).await;
assert_eq!(
got, want,
"hold {} reported {got:?} {when}; its capture reads at {}",
record.id,
record.source.display(),
);
}
fn assert_marker(root: &Path, want: &[u8], what: &str) {
let path = root.join(MARKER);
let got = std::fs::read(&path).unwrap_or_else(|err| panic!("reading {}: {err}", path.display()));
assert_eq!(
String::from_utf8_lossy(&got),
String::from_utf8_lossy(want),
"{what} carries the wrong value ({})",
path.display(),
);
}
async fn sole_hold(backup_type: &str) -> HoldRecord {
let mut mine: Vec<HoldRecord> = list()
.await
.expect("listing hold records")
.into_iter()
.filter(|record| record.backup_type == backup_type)
.collect();
assert_eq!(
mine.len(),
1,
"expected exactly one hold for {backup_type}, found {}",
mine.len(),
);
mine.remove(0)
}
async fn backup(backup_type: &str, backups_dir: &Path) -> Result<()> {
super::super::backup::run(
BackupArgs {
backup_type: backup_type.to_owned(),
config: None,
backups_dir: Some(backups_dir.to_path_buf()),
no_daemon: true,
hold: true,
no_upload: true,
},
Context::new(),
)
.await
}
async fn hold(action: HoldAction) -> Result<()> {
super::run(HoldArgs { action }, Context::new()).await
}
async fn restore(backup_type: &str, backups_dir: &Path, hold_id: &str) -> Result<()> {
super::super::restore::run(
RestoreArgs {
backup_type: backup_type.to_owned(),
id: None,
from_hold: Some(hold_id.to_owned()),
in_place: false,
target: None,
clobber: true,
no_followers: true,
config: None,
backups_dir: Some(backups_dir.to_path_buf()),
},
Context::new(),
)
.await
}
#[cfg(windows)]
async fn restore_err(record: &HoldRecord, backups_dir: &Path) -> String {
restore(&record.backup_type, backups_dir, &record.id)
.await
.expect_err("a restore from a capture that cannot be read has to refuse")
.to_string()
}
async fn clear_records(backup_type: &str) {
for id in stranded_holds(backup_type) {
if let Ok(record) = load(&id).await {
let _ = release(&record.capture).await;
}
let _ = std::fs::remove_file(records_dir().join(format!("{id}.json")));
}
}
fn stranded_holds(backup_type: &str) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(records_dir()) else {
return Vec::new();
};
entries
.flatten()
.filter_map(|entry| hold_id_of(&entry.file_name().to_string_lossy(), backup_type))
.collect()
}
fn hold_id_of(file_name: &str, backup_type: &str) -> Option<String> {
let id = file_name.strip_suffix(".json")?;
let stamp = id.strip_prefix(backup_type)?.strip_prefix('-')?;
is_stamp(stamp).then(|| id.to_owned())
}
fn is_stamp(text: &str) -> bool {
let bytes = text.as_bytes();
bytes.len() == 16
&& bytes[..8].iter().all(u8::is_ascii_digit)
&& bytes[8] == b'T'
&& bytes[9..15].iter().all(u8::is_ascii_digit)
&& bytes[15] == b'Z'
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::canopy::backup::hold::mint_id;
#[test]
fn a_type_does_not_claim_a_longer_types_holds() {
let at = "2026-09-15T05:37:06Z".parse().unwrap();
let own = format!("{}.json", mint_id("hold-e2e-vss", at));
let other = format!("{}.json", mint_id("hold-e2e-vss-reboot", at));
assert_eq!(
hold_id_of(&own, "hold-e2e-vss").as_deref(),
Some("hold-e2e-vss-20260915T053706Z"),
);
assert_eq!(hold_id_of(&other, "hold-e2e-vss"), None);
assert_eq!(
hold_id_of(&other, "hold-e2e-vss-reboot").as_deref(),
Some("hold-e2e-vss-reboot-20260915T053706Z"),
);
}
#[test]
fn only_this_types_record_files_match() {
for name in [
"hold-e2e-vss-20260915T053706Z", "hold-e2e-vss-notastamp.json", "hold-e2e-vss.json", "tamanu-postgres-20260915T053706Z.json", ] {
assert_eq!(hold_id_of(name, "hold-e2e-vss"), None, "{name}");
}
}
}
async fn read_store<B: Backend>(backend: &B, when: &str) -> f64 {
backend.store_in_use().await.unwrap_or_else(|| {
panic!(
"the {} backend reads its store, but could not {when}",
backend.expected_backend(),
)
})
}
async fn store_fell_by<B: Backend>(
backend: &B,
before: f64,
margin: f64,
) -> Result<(), String> {
let deadline = std::time::Instant::now() + RECLAIM_WITHIN;
let mut readings = 0_u32;
let mut lowest = f64::MAX;
loop {
let now = read_store(backend, "after the drop").await;
readings += 1;
lowest = lowest.min(now);
if before - now >= margin {
return Ok(());
}
if std::time::Instant::now() >= deadline {
return Err(format!(
"{readings} readings, lowest {lowest}, last {now}, so it fell by {} at best",
before - lowest,
));
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
fn write(path: &Path, contents: &[u8]) {
use std::io::Write as _;
let mut file = std::fs::File::create(path)
.unwrap_or_else(|err| panic!("creating {}: {err}", path.display()));
file.write_all(contents)
.unwrap_or_else(|err| panic!("writing {}: {err}", path.display()));
file.sync_all()
.unwrap_or_else(|err| panic!("flushing {}: {err}", path.display()));
sync_parent(path);
}
fn sync_parent(path: &Path) {
if let Some(parent) = path.parent()
&& let Ok(dir) = std::fs::File::open(parent)
{
let _ = dir.sync_all();
}
}
fn write_ballast(path: &Path, seed: u64) {
use std::io::Write as _;
const CHUNK: usize = 1024 * 1024;
let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
let mut chunk = Vec::with_capacity(CHUNK);
let mut file = std::fs::File::create(path)
.unwrap_or_else(|err| panic!("creating {}: {err}", path.display()));
let mut written = 0;
while written < BALLAST_BYTES {
let want = CHUNK.min(BALLAST_BYTES - written);
chunk.clear();
while chunk.len() < want {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
chunk.extend_from_slice(&state.to_le_bytes());
}
chunk.truncate(want);
file.write_all(&chunk)
.unwrap_or_else(|err| panic!("writing {}: {err}", path.display()));
written += want;
}
file.sync_all()
.unwrap_or_else(|err| panic!("flushing {}: {err}", path.display()));
sync_parent(path);
}
#[cfg(unix)]
mod unix;
#[cfg(windows)]
mod windows;