use std::path::{Path, PathBuf};
use miette::{Result, bail};
use tracing::info;
use super::{basis, room, sync};
use crate::actions::canopy::backup::hold::HoldRecord;
pub struct Job<'a> {
pub record: &'a HoldRecord,
pub capture: &'a Path,
pub live: &'a Path,
pub skip: Vec<PathBuf>,
}
#[derive(Debug, Default)]
pub struct Summary {
pub copied: usize,
pub removed: usize,
pub bytes: u64,
pub from_filesystem: bool,
}
pub struct Planned {
capture: PathBuf,
live: PathBuf,
delta: sync::Delta,
from_filesystem: bool,
}
impl Planned {
pub fn has_work(&self) -> bool {
self.delta.has_work()
}
}
pub async fn plan(job: Job<'_>) -> Result<Planned> {
let Job {
record,
capture,
live,
mut skip,
} = job;
let marker = PathBuf::from(Interlock::marker_name());
if !skip.contains(&marker) {
skip.push(marker);
}
sync::ensure_readable(capture).await?;
if !live.exists() {
info!(
live = %live.display(),
"the destination is not there, so the capture is laid down whole"
);
}
let basis = basis::resolve(record, live).await;
let from_filesystem = basis.is_named();
let delta = sync::compare(capture, live, &skip, basis.into_decision()).await?;
let planned = Planned {
capture: capture.to_path_buf(),
live: live.to_path_buf(),
delta,
from_filesystem,
};
if !planned.has_work() {
return Ok(planned);
}
#[cfg(unix)]
let pool = room::thin_pool(record).await;
#[cfg(unix)]
let cow_bytes = basis::divergence_bytes(record, live, pool.as_ref()).await;
#[cfg(unix)]
let store = room::cow_store(record, pool.as_ref()).await;
#[cfg(not(unix))]
let cow_bytes = basis::divergence_bytes(record, live).await;
#[cfg(not(unix))]
let store = room::cow_store(record).await;
room::ensure_room(live, &planned.delta, &store, cow_bytes).await?;
Ok(planned)
}
pub async fn lay_down(planned: Planned) -> Result<Summary> {
let Planned {
capture,
live,
delta,
from_filesystem,
} = planned;
if !delta.has_work() {
info!("the live tree already matches the capture; nothing to write");
return Ok(Summary {
from_filesystem,
..Default::default()
});
}
info!(
copying = delta.copy.len(),
removing = delta.remove.len(),
bytes = delta.copy_bytes,
"laying the divergence down over the live tree",
);
sync::apply(&capture, &live, &delta).await?;
Ok(Summary {
copied: delta.copy.len(),
removed: delta.remove.len(),
bytes: delta.copy_bytes,
from_filesystem,
})
}
pub struct Interlock {
marker: PathBuf,
}
const MARKER: &str = ".bestool-in-place-restore";
const HOLD_LINE: &str = "hold:";
impl Interlock {
pub fn marker_name() -> &'static str {
MARKER
}
pub fn marker_in(live: &Path) -> PathBuf {
live.join(MARKER)
}
pub async fn held_by(live: &Path) -> bool {
tokio::fs::symlink_metadata(Self::marker_in(live))
.await
.is_ok_and(|meta| meta.is_file())
}
pub async fn resumes(live: &Path, record: &HoldRecord) -> bool {
if !Self::held_by(live).await {
return false;
}
let Ok(note) = read_regular_file(Self::marker_in(live)).await else {
return false;
};
marked_hold(¬e).is_some_and(|held| held == record.id)
}
pub async fn engage(live: &Path, record: &HoldRecord) -> Result<Self> {
let marker = Self::marker_in(live);
let resuming = tokio::fs::metadata(&marker).await.is_ok();
let note = format!(
"This directory is part-way through an in-place restore and is NOT usable.\n\
\n\
hold: {}\n\
capture: {}\n\
frozen: {}\n\
started: {}\n\
\n\
It is neither the state it was in before nor the state that was captured.\n\
The hold is untouched, so run the same restore again to finish it. Do not\n\
start the service against this directory until that succeeds and removes\n\
this file.\n",
record.id,
record.source.display(),
record
.taken_at
.map_or_else(|| "unknown".to_owned(), |at| at.to_string()),
jiff::Timestamp::now(),
);
if let Some(parent) = marker.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
write_marker(&marker, note).await?;
if resuming {
info!(
live = %live.display(),
"resuming an in-place restore this directory was left part-way through",
);
}
Ok(Self { marker })
}
pub async fn release(self) -> Result<()> {
tokio::fs::remove_file(&self.marker).await.map_err(|err| {
miette::miette!(
"the restore finished but {} could not be removed, and while it is \
there the cluster is refused a start: {err}",
self.marker.display()
)
})
}
pub async fn ensure_clear(live: &Path) -> Result<()> {
if Self::held_by(live).await {
bail!(
"{} is part-way through an in-place restore, so it is neither the state \
it was in nor the state that was captured; finish the restore (run it \
again — the hold is untouched) before starting anything against it",
live.display()
);
}
Ok(())
}
}
async fn read_regular_file(path: PathBuf) -> std::io::Result<String> {
const MOST: u64 = 64 * 1024;
tokio::task::spawn_blocking(move || {
use std::io::Read as _;
let mut file = open_no_follow(&path, std::fs::OpenOptions::new().read(true))?;
let mut text = String::new();
file.by_ref().take(MOST).read_to_string(&mut text)?;
Ok(text)
})
.await
.map_err(std::io::Error::other)?
}
fn open_no_follow(path: &Path, options: &mut std::fs::OpenOptions) -> std::io::Result<std::fs::File> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.custom_flags(libc::O_NOFOLLOW);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt as _;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
options.open(path)
}
async fn write_marker(marker: &Path, note: String) -> Result<()> {
let marker = marker.to_path_buf();
tokio::task::spawn_blocking(move || {
use std::io::Write as _;
match marker.symlink_metadata() {
Ok(meta) if !meta.is_file() => {
let removed = if meta.is_dir() {
std::fs::remove_dir_all(&marker)
} else {
super::sync::remove_entry(&marker, false)
};
removed.map_err(|err| {
miette::miette!("clearing {} to mark it mid-restore: {err}", marker.display())
})?;
}
_ => {}
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
let mut file = open_no_follow(&marker, &mut options).map_err(|err| {
miette::miette!("marking {} as mid-restore: {err}", marker.display())
})?;
file.write_all(note.as_bytes()).map_err(|err| {
miette::miette!("marking {} as mid-restore: {err}", marker.display())
})
})
.await
.map_err(|err| miette::miette!("joining the marker write: {err}"))?
}
fn marked_hold(note: &str) -> Option<&str> {
note.lines()
.find_map(|line| line.trim().strip_prefix(HOLD_LINE))
.map(str::trim)
.filter(|id| !id.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::canopy::backup::hold::HeldCapture;
async fn run(job: Job<'_>) -> Result<Summary> {
lay_down(plan(job).await?).await
}
fn record(source: &Path) -> HoldRecord {
HoldRecord {
id: "tamanu-postgres-20260915T052531Z".into(),
backup_type: "tamanu-postgres".into(),
taken_at: Some("2026-09-15T05:25:31Z".parse().unwrap()),
held_at: "2026-09-15T05:25:40Z".parse().unwrap(),
source: source.to_path_buf(),
uploaded: false,
capture: HeldCapture::BaseBackup {
root: source.to_path_buf(),
},
diverged_since: None,
}
}
fn write(root: &Path, rel: &str, contents: &str) {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
#[tokio::test]
async fn rolls_the_live_tree_back_to_the_capture() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let live = tmp.path().join("live");
write(&capture, "PG_VERSION", "16");
write(&capture, "base/1/2345", "as it was at the freeze");
write(&live, "PG_VERSION", "16");
write(&live, "base/1/2345", "written after the freeze!!!");
write(&live, "base/1/9999", "created after the freeze");
let record = record(&capture);
let summary = run(Job {
record: &record,
capture: &capture,
live: &live,
skip: Vec::new(),
})
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(live.join("base/1/2345")).unwrap(),
"as it was at the freeze"
);
assert!(!live.join("base/1/9999").exists());
assert!(!summary.from_filesystem, "no basis was recorded for this capture");
}
#[tokio::test]
async fn a_tree_that_already_matches_is_reported_as_no_work() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let live = tmp.path().join("live");
write(&capture, "PG_VERSION", "16");
write(&capture, "base/1/2345", "the captured page");
write(&live, "PG_VERSION", "16");
write(&live, "base/1/2345", "the captured page");
let record = record(&capture);
let summary = run(Job {
record: &record,
capture: &capture,
live: &live,
skip: Vec::new(),
})
.await
.unwrap();
assert_eq!(summary.copied, 0);
assert_eq!(summary.removed, 0);
assert_eq!(summary.bytes, 0);
}
#[tokio::test]
async fn a_converged_resume_still_has_a_marker_to_clear() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let live = tmp.path().join("live");
write(&capture, "f", "as at the freeze");
write(&live, "f", "as at the freeze");
let record = record(&capture);
let interlock = Interlock::engage(&live, &record).await.unwrap();
drop(interlock);
let planned = plan(Job {
record: &record,
capture: &capture,
live: &live,
skip: Vec::new(),
})
.await
.unwrap();
assert!(!planned.has_work(), "the tree already matches the capture");
assert!(
Interlock::resumes(&live, &record).await,
"and yet it is still marked mid-restore, which is what has to be acted on"
);
}
#[tokio::test]
async fn an_interrupted_restore_is_resumed_by_running_it_again() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let live = tmp.path().join("live");
write(&capture, "base/1/2345", "as at the freeze");
write(&capture, "base/1/3456", "also as at the freeze");
write(&live, "base/1/2345", "diverged since then");
write(&live, "base/1/9999", "created since then");
let record = record(&capture);
let interlock = Interlock::engage(&live, &record).await.unwrap();
std::fs::write(live.join("base/1/2345"), "as at the freeze").unwrap();
drop(interlock);
assert!(Interlock::held_by(&live).await, "the tree is still marked mid-restore");
let interlock = Interlock::engage(&live, &record).await.unwrap();
run(Job {
record: &record,
capture: &capture,
live: &live,
skip: vec![PathBuf::from(
Interlock::marker_in(Path::new("")).file_name().unwrap(),
)],
})
.await
.unwrap();
interlock.release().await.unwrap();
assert_eq!(
std::fs::read_to_string(live.join("base/1/3456")).unwrap(),
"also as at the freeze"
);
assert!(!live.join("base/1/9999").exists());
Interlock::ensure_clear(&live).await.unwrap();
}
#[tokio::test]
async fn an_empty_capture_is_refused_before_anything_is_written() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let live = tmp.path().join("live");
std::fs::create_dir_all(&capture).unwrap();
write(&live, "PG_VERSION", "16");
let record = record(&capture);
let err = run(Job {
record: &record,
capture: &capture,
live: &live,
skip: Vec::new(),
})
.await
.unwrap_err()
.to_string();
assert!(err.contains("erase the cluster"), "got: {err}");
assert!(
live.join("PG_VERSION").exists(),
"the live tree must be untouched when the capture is refused"
);
}
#[tokio::test]
async fn a_skipped_path_is_left_alone_on_both_sides() {
let tmp = tempfile::tempdir().unwrap();
let capture = tmp.path().join("capture");
let live = tmp.path().join("live");
write(&capture, "PG_VERSION", "16");
write(&capture, "data", "as it was at the freeze");
write(&live, "data", "diverged");
let record = record(&capture);
run(Job {
record: &record,
capture: &capture,
live: &live,
skip: vec![PathBuf::from("PG_VERSION")],
})
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(live.join("data")).unwrap(),
"as it was at the freeze"
);
assert!(
!live.join("PG_VERSION").exists(),
"the interlock's own file is written back by the interlock, not the sync"
);
}
#[tokio::test]
async fn the_interlock_refuses_a_start_while_it_is_engaged_and_permits_one_after() {
let tmp = tempfile::tempdir().unwrap();
let live = tmp.path().join("live");
std::fs::create_dir_all(&live).unwrap();
let record = record(&live);
Interlock::ensure_clear(&live).await.unwrap();
let interlock = Interlock::engage(&live, &record).await.unwrap();
assert!(Interlock::held_by(&live).await);
let err = Interlock::ensure_clear(&live).await.unwrap_err().to_string();
assert!(err.contains("part-way through"), "got: {err}");
interlock.release().await.unwrap();
Interlock::ensure_clear(&live).await.unwrap();
}
#[tokio::test]
async fn a_marker_from_this_hold_is_a_resume() {
let tmp = tempfile::tempdir().unwrap();
let live = tmp.path().join("live");
std::fs::create_dir_all(&live).unwrap();
let record = record(&live);
assert!(!Interlock::resumes(&live, &record).await, "no marker, no resume");
let _interlock = Interlock::engage(&live, &record).await.unwrap();
assert!(Interlock::resumes(&live, &record).await);
}
#[tokio::test]
async fn a_marker_from_another_hold_is_not_consent() {
let tmp = tempfile::tempdir().unwrap();
let live = tmp.path().join("live");
std::fs::create_dir_all(&live).unwrap();
let mine = record(&live);
let _interlock = Interlock::engage(&live, &mine).await.unwrap();
let mut other = record(&live);
other.id = "tamanu-postgres-20260101T000000Z".into();
assert!(Interlock::held_by(&live).await, "the tree is mid-restore");
assert!(
!Interlock::resumes(&live, &other).await,
"a marker for a different hold must not stand in for confirmation"
);
}
#[tokio::test]
async fn a_marker_naming_no_hold_is_not_consent() {
let tmp = tempfile::tempdir().unwrap();
let live = tmp.path().join("live");
std::fs::create_dir_all(&live).unwrap();
let record = record(&live);
std::fs::write(Interlock::marker_in(&live), "planted by someone else
").unwrap();
assert!(Interlock::held_by(&live).await);
assert!(!Interlock::resumes(&live, &record).await);
}
#[cfg(unix)]
#[tokio::test]
async fn a_symlink_naming_the_hold_is_not_consent() {
let tmp = tempfile::tempdir().unwrap();
let live = tmp.path().join("live");
std::fs::create_dir_all(&live).unwrap();
let record = record(&live);
let elsewhere = tmp.path().join("bait");
std::fs::write(&elsewhere, format!("hold: {}\n", record.id)).unwrap();
std::os::unix::fs::symlink(&elsewhere, Interlock::marker_in(&live)).unwrap();
assert!(
!Interlock::resumes(&live, &record).await,
"a link is not a marker this wrote, so it cannot stand in for a confirmation"
);
}
#[cfg(unix)]
#[tokio::test]
async fn a_symlink_standing_in_for_the_marker_is_not_written_through() {
let tmp = tempfile::tempdir().unwrap();
let live = tmp.path().join("live");
std::fs::create_dir_all(&live).unwrap();
let elsewhere = tmp.path().join("precious");
std::fs::write(&elsewhere, "must not be touched").unwrap();
std::os::unix::fs::symlink(&elsewhere, Interlock::marker_in(&live)).unwrap();
let record = record(&live);
assert!(!Interlock::held_by(&live).await, "a link is not a marker this wrote");
let interlock = Interlock::engage(&live, &record).await.unwrap();
assert_eq!(
std::fs::read_to_string(&elsewhere).unwrap(),
"must not be touched",
"the file the link pointed at must be untouched"
);
assert!(Interlock::marker_in(&live).symlink_metadata().unwrap().is_file());
assert!(Interlock::resumes(&live, &record).await);
interlock.release().await.unwrap();
}
#[test]
fn reads_the_hold_out_of_a_marker_and_nothing_else_out_of_the_prose() {
let note = "This directory is part-way through an in-place restore and is NOT usable.\n\
\n\
hold: tamanu-postgres-20260915T052531Z\n\
capture: /var/lib/bestool/held-source/x\n";
assert_eq!(marked_hold(note), Some("tamanu-postgres-20260915T052531Z"));
assert_eq!(marked_hold("no hold line here"), None);
assert_eq!(marked_hold("hold: \n"), None);
}
#[tokio::test]
async fn the_marker_names_the_hold_to_resume_from() {
let tmp = tempfile::tempdir().unwrap();
let live = tmp.path().join("live");
std::fs::create_dir_all(&live).unwrap();
let record = record(&live);
let _interlock = Interlock::engage(&live, &record).await.unwrap();
let note = std::fs::read_to_string(Interlock::marker_in(&live)).unwrap();
assert!(note.contains("tamanu-postgres-20260915T052531Z"), "got: {note}");
assert!(note.contains("2026-09-15T05:25:31Z"), "got: {note}");
}
}