use std::ffi::OsStr;
use std::path::Path;
use std::time::SystemTime;
use anyhow::Result;
use mj_checkpoint::checkpoint::CHECKPOINT_CAPTURE_TEMPORARY_PREFIX;
use crate::session_manager::new_command_id;
use crate::targets::CommandExecutor;
const OPERATION_PREFIX: &str = "checkpoint";
pub(super) struct TargetCheckpointOperation {
pub(super) id: String,
pub(super) stage: String,
pub(super) archive: String,
}
impl TargetCheckpointOperation {
pub(super) fn new(worker_root: &str) -> Result<Self> {
let id = new_command_id(OPERATION_PREFIX)?;
Ok(Self {
stage: format!("{worker_root}/{id}-stage"),
archive: format!("{worker_root}/{id}.hel.zip"),
id,
})
}
}
fn is_checkpoint_leftover(name: &OsStr) -> bool {
let Some(name) = name.to_str() else {
return false;
};
if name.starts_with(CHECKPOINT_CAPTURE_TEMPORARY_PREFIX) {
return true;
}
let Some(nonce) = name
.strip_prefix(OPERATION_PREFIX)
.and_then(|rest| rest.strip_prefix('-'))
.and_then(|rest| {
rest.strip_suffix("-stage")
.or_else(|| rest.strip_suffix(".hel.zip"))
})
else {
return false;
};
nonce.len() == 32
&& nonce
.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct CheckpointLeftovers {
pub count: usize,
pub bytes: u64,
}
pub fn sweep_local_checkpoint_leftovers(
executor: &impl CommandExecutor,
started: SystemTime,
stop: &dyn Fn() -> bool,
) -> CheckpointLeftovers {
let Some(running) = crate::controller::local_profile_homes::running_process_arguments(executor)
else {
tracing::warn!("left checkpoint files in local worker roots in place");
return CheckpointLeftovers::default();
};
let swept = sweep_checkpoint_leftovers_in(
&mj_core::config::data_dir().join("workers"),
started,
&running,
stop,
);
if swept.count > 0 {
tracing::info!(
count = swept.count,
bytes = swept.bytes,
"removed checkpoint files that earlier checkpoints left in local worker roots"
);
}
swept
}
pub(super) fn sweep_checkpoint_leftovers_in(
workers: &Path,
started: SystemTime,
running: &[String],
stop: &dyn Fn() -> bool,
) -> CheckpointLeftovers {
let mut swept = CheckpointLeftovers::default();
let roots = match std::fs::read_dir(workers) {
Ok(roots) => roots,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return swept,
Err(error) => {
tracing::warn!(
path = %workers.display(),
%error,
"could not scan local worker roots for checkpoint files"
);
return swept;
}
};
for root in roots.flatten().map(|entry| entry.path()) {
if !std::fs::symlink_metadata(&root).is_ok_and(|metadata| metadata.is_dir())
|| checkpoint_command_running(&root, running)
{
continue;
}
let entries = match std::fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) => {
tracing::warn!(
path = %root.display(),
%error,
"could not scan a local worker root for checkpoint files"
);
continue;
}
};
for entry in entries.flatten() {
if stop() {
return swept;
}
if !is_checkpoint_leftover(&entry.file_name()) {
continue;
}
let path = entry.path();
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if !metadata.modified().is_ok_and(|modified| modified < started) {
continue;
}
let (bytes, removed) = if metadata.is_dir() {
(tree_bytes(&path), std::fs::remove_dir_all(&path))
} else if metadata.is_file() {
(metadata.len(), std::fs::remove_file(&path))
} else {
continue;
};
match removed {
Ok(()) => {
swept.count += 1;
swept.bytes = swept.bytes.saturating_add(bytes);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(
path = %path.display(),
%error,
"could not remove a checkpoint file left in a local worker root"
),
}
}
}
swept
}
fn checkpoint_command_running(root: &Path, running: &[String]) -> bool {
let binary = format!("{}/hel worker ", root.display());
running.iter().any(|arguments| {
arguments.strip_prefix(&binary).is_some_and(|subcommand| {
["capture-checkpoint", "pack-checkpoint", "export-checkpoint"]
.iter()
.any(|name| subcommand.starts_with(name))
})
})
}
fn tree_bytes(path: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(path) else {
return 0;
};
entries
.flatten()
.map(|entry| match entry.metadata() {
Ok(metadata) if metadata.is_dir() => tree_bytes(&entry.path()),
Ok(metadata) if metadata.is_file() => metadata.len(),
_ => 0,
})
.fold(0, u64::saturating_add)
}
#[cfg(all(test, unix))]
mod tests {
use std::path::PathBuf;
use std::time::Duration;
use super::*;
const ID: &str = "0123456789abcdef0123456789abcdef";
const OTHER_ID: &str = "fedcba9876543210fedcba9876543210";
fn write(path: &Path, bytes: usize) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, vec![1_u8; bytes]).unwrap();
}
fn set_modified(path: &Path, time: SystemTime) {
std::fs::File::open(path)
.unwrap()
.set_modified(time)
.unwrap();
}
#[test]
fn the_sweep_removes_only_checkpoint_files_nothing_can_still_own() {
let directory = tempfile::tempdir().unwrap();
let workers = directory.path().join("workers");
let started = SystemTime::now();
let before = started - Duration::from_secs(3600);
let after = started + Duration::from_secs(60);
let root = workers.join("session-a");
let stage = root.join(format!("checkpoint-{ID}-stage"));
write(&stage.join("native/00000000"), 1000);
write(&stage.join("stage.json"), 2);
let capture = root.join(".checkpoint-capture-Ab12Cd");
write(&capture.join("native/00000000"), 500);
let archive = root.join(format!("checkpoint-{ID}.hel.zip"));
write(&archive, 300);
let current = root.join(format!("checkpoint-{OTHER_ID}-stage"));
write(¤t.join("stage.json"), 2);
let kept = [
root.join("launch.json"),
root.join("restore.hel.zip"),
root.join("profile/auth.json"),
root.join("checkpoint-notes-stage/file"),
root.join(format!("checkpoint-{ID}-stage.json")),
];
for path in &kept {
write(path, 10);
}
let outside = directory.path().join("outside");
write(&outside.join("keep"), 10);
let link = root.join(format!("checkpoint-{}-stage", "a".repeat(32)));
std::os::unix::fs::symlink(&outside, &link).unwrap();
let busy = workers.join("session-b");
let busy_stage = busy.join(format!("checkpoint-{ID}-stage"));
write(&busy_stage.join("stage.json"), 2);
let running = vec![
format!(
"{}/hel worker run --root {}",
root.display(),
root.display()
),
format!("{}/hel worker capture-checkpoint", busy.display()),
];
let pinned = workers.join("pinned/hel");
write(&pinned, 10);
for path in [&stage, &capture, &archive, &busy_stage] {
set_modified(path, before);
}
for path in &kept {
set_modified(path, before);
}
set_modified(¤t, after);
let swept = sweep_checkpoint_leftovers_in(&workers, started, &running, &|| false);
assert_eq!(
swept,
CheckpointLeftovers {
count: 3,
bytes: 1802
}
);
for removed in [&stage, &capture, &archive] {
assert!(!removed.exists(), "{} was kept", removed.display());
}
let expected_kept: Vec<PathBuf> = kept
.iter()
.cloned()
.chain([current, busy_stage, pinned, outside.join("keep")])
.collect();
for path in &expected_kept {
assert!(path.exists(), "{} was removed", path.display());
}
assert!(std::fs::symlink_metadata(&link).is_ok());
}
}