use std::collections::{HashMap, HashSet};
use std::convert::Infallible;
use std::path::Path;
use std::time::{Duration, SystemTime};
use secrecy::SecretString;
use super::snapshot::{Proof, Snapshot, read_proof};
use super::{MetricsSummary, RunRecord, RunState, Runs, RunsError, new_run_id, rfc3339};
use crate::events::{Event, EventBus};
use crate::exec::{
ExecError, Executor, FileDigest, JOB_LOG, JobId, JobRuntime, JobSpec, JobStatus, LineStream,
Stages, local_manifest,
};
use crate::runpod::{POD_FILE, SSH_DIR, chain};
use crate::system::{self, SystemSample, probe_script};
use crate::train::{SNAPSHOT_FILE, TrainError, Trainer};
pub const HF_CACHE_DIR: &str = ".hf-cache";
pub const TOOLS_CACHE_DIR: &str = ".cache";
const MAX_FAILURES: u32 = 5;
pub const PROBE_EVERY: Duration = Duration::from_secs(10);
const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
const NOT_RETRIEVED: &str = "artifacts not retrieved: ";
#[derive(Debug, thiserror::Error)]
pub enum RunError {
#[error(transparent)]
Runs(#[from] RunsError),
#[error(transparent)]
Train(#[from] TrainError),
#[error(transparent)]
Exec(#[from] ExecError),
#[error("run {0} has no job: it stopped before the job started")]
NotStarted(String),
#[error(
"{0} on the target belongs to another run with the same ID, from another checkout of the project; start the run again"
)]
Taken(String),
#[error("run {0} is not running ({1}): there is nothing to snapshot")]
NotRunning(String, String),
#[error(
"run {0} was started by an overbrainer older than 0.5.0: its job cannot save a snapshot; cancel it with `overbrainer train cancel {0}`, or let it finish"
)]
NoSnapshots(String),
#[error(
"run {0} has not ended yet: watch or attach it, not collect, while it is preparing or running"
)]
NotEnded(String),
}
#[derive(Debug, Clone, Copy)]
pub struct RunCtx<'a, E> {
pub runs: &'a Runs,
pub executor: &'a E,
pub bus: &'a EventBus,
pub poll: Duration,
}
#[derive(Debug)]
pub struct Launch<'a> {
pub runtime: &'a JobRuntime,
pub secrets: Vec<(String, SecretString)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Outcome {
pub record: RunRecord,
pub summary: MetricsSummary,
pub retrieved: bool,
}
pub fn create(
runs: &Runs,
project: &str,
workdir: &str,
target: &str,
) -> Result<RunRecord, RunsError> {
let now = SystemTime::now();
let (id, _) = runs.claim(&new_run_id(project, now), 0)?;
let record = preparing(id, workdir, target, now);
runs.save(&record)?;
Ok(record)
}
pub async fn create_on<E: Executor>(
runs: &Runs,
executor: &E,
project: &str,
target: &str,
) -> Result<RunRecord, RunError> {
let now = SystemTime::now();
create_named(runs, executor, &new_run_id(project, now), target, now).await
}
async fn create_named<E: Executor>(
runs: &Runs,
executor: &E,
base: &str,
target: &str,
now: SystemTime,
) -> Result<RunRecord, RunError> {
let owner = format!("{:016x}", fastrand::u64(..));
let mut after = 0;
loop {
let (id, n) = runs.claim(base, after)?;
let remote_dir = format!("{}/{id}", executor.workdir());
match executor.claim(&remote_dir, &owner).await {
Ok(true) => {
let record = preparing(id, executor.workdir(), target, now);
runs.save(&record)?;
return Ok(record);
},
Ok(false) => {
tracing::debug!("{remote_dir} belongs to another run: trying the next ID");
runs.release(&id);
after = n;
},
Err(error) => {
runs.release(&id);
return Err(error.into());
},
}
}
}
pub async fn reserve<E: Executor>(
ctx: &RunCtx<'_, E>,
record: &mut RunRecord,
owner: &str,
) -> Result<(), RunError> {
let error = match ctx.executor.claim(&record.remote_dir, owner).await {
Ok(true) => return Ok(()),
Ok(false) => RunError::Taken(record.remote_dir.clone()),
Err(error) => error.into(),
};
record.state = RunState::Failed;
record.message = Some(error.to_string());
if let Err(save_error) = ctx.runs.save(record) {
tracing::warn!("cannot record run {} as failed: {save_error}", record.id);
}
Err(error)
}
fn preparing(id: String, workdir: &str, target: &str, now: SystemTime) -> RunRecord {
RunRecord {
remote_dir: format!("{workdir}/{id}"),
id,
target: target.to_string(),
created: rfc3339(now),
job: None,
state: RunState::Preparing,
message: None,
snapshot: None,
resumed_from: None,
snapshots: false,
}
}
pub async fn start<E: Executor, T: Trainer>(
ctx: &RunCtx<'_, E>,
trainer: &T,
launch: Launch<'_>,
record: RunRecord,
) -> Result<RunRecord, RunError> {
start_in(ctx, trainer, launch, record, None).await
}
pub async fn start_mounted<E: Executor, T: Trainer>(
ctx: &RunCtx<'_, E>,
trainer: &T,
launch: Launch<'_>,
record: RunRecord,
mount: &str,
) -> Result<RunRecord, RunError> {
start_in(ctx, trainer, launch, record, Some(mount)).await
}
async fn start_in<E: Executor, T: Trainer>(
ctx: &RunCtx<'_, E>,
trainer: &T,
launch: Launch<'_>,
mut record: RunRecord,
mount: Option<&str>,
) -> Result<RunRecord, RunError> {
match launch_job(ctx, trainer, launch, &record, mount).await {
Ok(job) => record_started(ctx, record, job, trainer.stop_marker().is_some()).await,
Err(error) => {
record.state = RunState::Failed;
record.message = Some(error.to_string());
if let Err(save_error) = ctx.runs.save(&record) {
tracing::warn!("cannot record run {} as failed: {save_error}", record.id);
}
Err(error)
},
}
}
async fn record_started<E: Executor>(
ctx: &RunCtx<'_, E>,
mut record: RunRecord,
job: JobId,
snapshots: bool,
) -> Result<RunRecord, RunError> {
record.job = Some(job.clone());
record.state = RunState::Running;
record.snapshots = snapshots;
let Err(error) = ctx.runs.save(&record) else {
return Ok(record);
};
if let Err(cancel_error) = ctx.executor.cancel(&job).await {
tracing::warn!(
"cannot cancel the job of run {}, which is not recorded: {cancel_error}",
record.id
);
}
Err(error.into())
}
async fn launch_job<E: Executor, T: Trainer>(
ctx: &RunCtx<'_, E>,
trainer: &T,
launch: Launch<'_>,
record: &RunRecord,
mount: Option<&str>,
) -> Result<JobId, RunError> {
let local = ctx.runs.run_dir(&record.id)?;
let (run_dir, root) = match mount {
None => (
record.remote_dir.as_str(),
launch.runtime.root(&record.remote_dir),
),
Some(mount) => {
let inside = record
.remote_dir
.strip_prefix(mount)
.filter(|rest| rest.starts_with('/'))
.ok_or_else(|| {
ExecError::Protocol(format!("{} is not inside {mount}", record.remote_dir))
})?;
(mount, format!("{}{inside}", launch.runtime.root(mount)))
},
};
trainer.prepare(&local, &root)?;
let local_only = [SSH_DIR.to_string(), POD_FILE.to_string()];
ctx.executor
.upload(&local, &record.remote_dir, &local_only)
.await?;
let cache_dir = format!("{}/{HF_CACHE_DIR}", ctx.executor.workdir());
let tools_dir = format!("{}/{TOOLS_CACHE_DIR}", ctx.executor.workdir());
let metrics = match mount {
None => trainer.metrics_file().to_string(),
Some(_) => format!("{root}/{}", trainer.metrics_file()),
};
let mut job = launch.runtime.job(JobSpec {
run_id: &record.id,
run_dir,
cache_dir: &cache_dir,
commands: &trainer.commands(),
env: &trainer.env(&root),
stop_marker: trainer.stop_marker(),
stages: Some(Stages {
file: &metrics,
names: &trainer.stages(),
}),
secrets: launch.secrets,
tools_dir: trainer.caches_tools().then_some(tools_dir.as_str()),
});
job.dir.clone_from(&record.remote_dir);
Ok(ctx.executor.spawn(&job).await?)
}
pub async fn watch<E: Executor, T: Trainer>(
ctx: &RunCtx<'_, E>,
trainer: &T,
mut record: RunRecord,
) -> Result<Outcome, RunError> {
let job = record
.job
.clone()
.ok_or_else(|| RunError::NotStarted(record.id.clone()))?;
let local = ctx.runs.run_dir(&record.id)?;
if record.state != RunState::Running {
let summary = local_summary(&local.join(trainer.metrics_file()))?;
let retrieved = !artifacts_missing(&record)
&& local.join(JOB_LOG).is_file()
&& record
.snapshot
.as_ref()
.is_none_or(|snapshot| local.join(&snapshot.checkpoint).is_dir());
return Ok(Outcome {
record,
summary,
retrieved,
});
}
ctx.bus.publish(Event::RunWatched {
run_id: record.id.clone(),
job: ctx.runs.job_of().map(|(_, kind)| kind),
});
let metrics = format!("{}/{}", record.remote_dir, trainer.metrics_file());
let mut stream = ctx.executor.tail(&metrics, 0);
let mut summary = MetricsSummary::default();
let sampler = Box::pin(sample(ctx, &record.remote_dir));
let status = tokio::select! {
status = follow(ctx, &job, &mut stream, &mut summary) => status?,
never = sampler => match never {},
};
let dir = ctx.runs.relative_dir(&record.id);
let (state, message) = outcome(status, &summary, &dir, trainer.metrics_required());
let (state, message, snapshot) = if matches!(state, RunState::Succeeded | RunState::Cancelled) {
match read_proof(ctx.executor, &record.remote_dir).await? {
Proof::None => (state, message, None),
Proof::Saved(snapshot) => (RunState::Stopped, None, Some(snapshot)),
Proof::Invalid(_) if state == RunState::Cancelled => (state, message, None),
Proof::Invalid(why) => (
RunState::Failed,
Some(format!("the job stopped with a snapshot, but {why}")),
None,
),
}
} else {
(state, message, None)
};
let expect = Expect::of(state, snapshot.as_ref());
let kept = matches!(state, RunState::Succeeded | RunState::Stopped);
let (state, message, retrieved) =
match retrieve(ctx.executor, trainer, &record.remote_dir, &local, expect).await {
Ok(Retrieved::Ok) => (state, message, true),
Ok(Retrieved::NoOutput) => (
RunState::Failed,
Some(no_output_message(&dir, snapshot.as_ref())),
true,
),
Err(error) if kept => return Err(error.into()),
Err(error) => (state, Some(not_retrieved(message, &error)), false),
};
record.message = message;
record.snapshot = snapshot.filter(|_| state == RunState::Stopped);
record.state = state;
ctx.runs.save(&record)?;
Ok(Outcome {
record,
summary,
retrieved,
})
}
#[derive(Debug, Clone, Copy)]
enum Expect<'a> {
Nothing,
Output,
Snapshot(&'a Snapshot),
}
impl<'a> Expect<'a> {
fn of(state: RunState, snapshot: Option<&'a Snapshot>) -> Self {
match (state, snapshot) {
(RunState::Succeeded, _) => Self::Output,
(RunState::Stopped, Some(snapshot)) => Self::Snapshot(snapshot),
_ => Self::Nothing,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Retrieved {
Ok,
NoOutput,
}
async fn retrieve<E: Executor, T: Trainer>(
executor: &E,
trainer: &T,
remote: &str,
local: &Path,
expect: Expect<'_>,
) -> Result<Retrieved, ExecError> {
let artifacts = trainer.artifacts();
let mut entries = artifacts.entries;
entries.push(JOB_LOG.to_string());
let manifest = Box::pin(retrieve_pass(
executor,
remote,
local,
entries,
artifacts.exclude,
))
.await?;
let found = match expect {
Expect::Nothing => true,
Expect::Output => artifacts
.required
.as_deref()
.is_none_or(|required| has_required(&manifest, required)),
Expect::Snapshot(snapshot) => {
let entries = vec![snapshot.checkpoint.clone(), SNAPSHOT_FILE.to_string()];
let manifest =
Box::pin(retrieve_pass(executor, remote, local, entries, Vec::new())).await?;
has_required(&manifest, &snapshot.checkpoint)
},
};
Ok(if found {
Retrieved::Ok
} else {
Retrieved::NoOutput
})
}
async fn retrieve_pass<E: Executor>(
executor: &E,
remote: &str,
local: &Path,
entries: Vec<String>,
exclude: Vec<String>,
) -> Result<Vec<FileDigest>, ExecError> {
let manifest = executor.manifest(remote, &entries, &exclude).await?;
executor.download(remote, local, &entries, &exclude).await?;
let local = local.to_path_buf();
let listed = manifest.clone();
tokio::task::spawn_blocking(move || verify(&local, &entries, &exclude, &listed))
.await
.map_err(|error| ExecError::Protocol(format!("the verification task failed: {error}")))??;
Ok(manifest)
}
fn has_required(manifest: &[FileDigest], required: &str) -> bool {
let inside = format!("{required}/");
manifest
.iter()
.any(|file| file.path == required || file.path.starts_with(&inside))
}
fn verify(
local: &Path,
entries: &[String],
exclude: &[String],
manifest: &[FileDigest],
) -> Result<(), ExecError> {
let local_files = local_manifest(local, entries, exclude)?;
let local_by_path: HashMap<&str, &str> = local_files
.iter()
.map(|file| (file.path.as_str(), file.sha256.as_str()))
.collect();
for file in manifest {
let Some(sha256) = local_by_path.get(file.path.as_str()).copied() else {
return Err(verify_error(format!("{:?} is missing locally", file.path)));
};
if sha256 != file.sha256.as_str() {
return Err(verify_error(format!(
"{:?} differs from the target (SHA-256 mismatch)",
file.path
)));
}
}
let manifest_paths: HashSet<&str> = manifest.iter().map(|file| file.path.as_str()).collect();
if let Some(extra) = local_files
.iter()
.find(|file| !manifest_paths.contains(file.path.as_str()))
{
return Err(verify_error(format!(
"{:?} is not on the target",
extra.path
)));
}
Ok(())
}
fn verify_error(message: String) -> ExecError {
ExecError::Command {
action: "verify",
message,
}
}
fn no_output_message(dir: &str, snapshot: Option<&Snapshot>) -> String {
match snapshot {
Some(snapshot) => format!(
"the job stopped with a snapshot but left no file in {} (see {dir}/{JOB_LOG})",
snapshot.checkpoint
),
None => format!("the job succeeded but left no output (see {dir}/{JOB_LOG})"),
}
}
fn not_retrieved(message: Option<String>, error: &ExecError) -> String {
match message {
Some(message) => format!("{message} ({NOT_RETRIEVED}{error})"),
None => format!("{NOT_RETRIEVED}{error}"),
}
}
#[must_use]
pub fn artifacts_missing(record: &RunRecord) -> bool {
record
.message
.as_deref()
.is_some_and(|message| message.contains(NOT_RETRIEVED))
}
fn without_note(message: Option<String>) -> Option<String> {
let message = message?;
if message.starts_with(NOT_RETRIEVED) {
return None;
}
match message.find(&format!(" ({NOT_RETRIEVED}")) {
Some(index) => Some(message[..index].to_string()),
None => Some(message),
}
}
pub async fn collect<E: Executor, T: Trainer>(
ctx: &RunCtx<'_, E>,
trainer: &T,
mut record: RunRecord,
) -> Result<(RunRecord, bool), RunError> {
if matches!(record.state, RunState::Preparing | RunState::Running) {
return Err(RunError::NotEnded(record.id));
}
let local = ctx.runs.run_dir(&record.id)?;
let expect = Expect::of(record.state, record.snapshot.as_ref());
if let Err(error) = retrieve(ctx.executor, trainer, &record.remote_dir, &local, expect).await {
tracing::warn!(
"cannot retrieve the artifacts of run {}: {error}",
record.id
);
return Ok((record, false));
}
if artifacts_missing(&record) {
record.message = without_note(record.message.take());
ctx.runs.save(&record)?;
}
Ok((record, true))
}
async fn follow<E: Executor>(
ctx: &RunCtx<'_, E>,
job: &JobId,
stream: &mut LineStream<'_, E>,
summary: &mut MetricsSummary,
) -> Result<JobStatus, RunError> {
let mut failures = 0;
let mut last = None;
let status = loop {
match poll(ctx, job, stream, summary).await {
Ok(status) => {
failures = 0;
if last != Some(status) {
ctx.bus.publish(Event::JobStatus(status));
last = Some(status);
}
if status.is_finished() {
break status;
}
},
Err(error) => retry(&mut failures, error)?,
}
tokio::time::sleep(ctx.poll).await;
};
loop {
let offset = stream.offset();
match stream.read().await {
Ok(lines) => {
let drained = lines.is_empty() && stream.offset() == offset;
publish(ctx.bus, lines, summary);
if drained {
return Ok(status);
}
},
Err(error) => {
retry(&mut failures, error)?;
tokio::time::sleep(ctx.poll).await;
},
}
}
}
async fn sample<E: Executor>(ctx: &RunCtx<'_, E>, run_dir: &str) -> Infallible {
let script = probe_script(run_dir);
let mut previous: Option<SystemSample> = None;
let mut every = tokio::time::interval(PROBE_EVERY);
every.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
every.tick().await;
match sample_once(ctx.executor, &script, previous.as_ref()).await {
Ok(sample) => {
ctx.bus.publish(Event::System(sample.clone()));
previous = Some(sample);
},
Err(error) => tracing::debug!("cannot sample the target: {error}"),
}
}
}
async fn sample_once<E: Executor>(
executor: &E,
script: &str,
previous: Option<&SystemSample>,
) -> Result<SystemSample, String> {
let output = tokio::time::timeout(PROBE_TIMEOUT, executor.probe(script))
.await
.map_err(|_| format!("no answer within {}s", PROBE_TIMEOUT.as_secs()))?
.map_err(|error| chain(&error))?;
let text = String::from_utf8_lossy(&output);
Ok(system::parse(&text, SystemTime::now(), previous))
}
fn retry(failures: &mut u32, error: ExecError) -> Result<(), RunError> {
if *failures < MAX_FAILURES {
*failures += 1;
unreachable_target(&error, *failures);
Ok(())
} else {
Err(error.into())
}
}
async fn poll<E: Executor>(
ctx: &RunCtx<'_, E>,
job: &JobId,
stream: &mut LineStream<'_, E>,
summary: &mut MetricsSummary,
) -> Result<JobStatus, ExecError> {
publish(ctx.bus, stream.read().await?, summary);
ctx.executor.status(job).await
}
fn publish(bus: &EventBus, lines: Vec<String>, summary: &mut MetricsSummary) {
for line in lines {
if let Some(event) = summary.add(&line) {
bus.publish(event);
}
}
}
fn unreachable_target(error: &ExecError, failures: u32) {
tracing::warn!("{}", unreachable_line(error, failures));
}
fn unreachable_line(error: &ExecError, failures: u32) -> String {
format!(
"cannot reach the job ({failures}/{MAX_FAILURES}): {}",
chain(error)
)
}
fn outcome(
status: JobStatus,
summary: &MetricsSummary,
dir: &str,
metrics_required: bool,
) -> (RunState, Option<String>) {
let log = format!("see {dir}/{JOB_LOG}");
match status {
JobStatus::Exited(0) if summary.lines > 0 || !metrics_required => {
(RunState::Succeeded, None)
},
JobStatus::Exited(0) => (
RunState::Failed,
Some(format!(
"the job wrote no metrics: the overbrainer metrics plugin was not loaded ({log})"
)),
),
JobStatus::Exited(code) => (
RunState::Failed,
Some(format!("the job exited with code {code} ({log})")),
),
JobStatus::Cancelled => (RunState::Cancelled, None),
JobStatus::Lost | JobStatus::Running => (
RunState::Failed,
Some(format!("the job stopped without an exit code ({log})")),
),
}
}
fn local_summary(path: &Path) -> Result<MetricsSummary, RunError> {
let content = match std::fs::read(path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(source) => {
return Err(RunError::Runs(RunsError::Io {
path: path.to_path_buf(),
source,
}));
},
};
let mut summary = MetricsSummary::default();
for line in String::from_utf8_lossy(&content).lines() {
if !line.trim().is_empty() {
summary.add(line);
}
}
Ok(summary)
}
pub async fn cancel<E: Executor, T: Trainer>(
runs: &Runs,
executor: &E,
trainer: &T,
mut record: RunRecord,
) -> Result<(RunRecord, JobStatus, bool), RunError> {
let job = record
.job
.clone()
.ok_or_else(|| RunError::NotStarted(record.id.clone()))?;
executor.cancel(&job).await?;
let status = executor.status(&job).await?;
let mut retrieved = false;
if status == JobStatus::Cancelled {
record.state = RunState::Cancelled;
record.message = None;
runs.save(&record)?;
retrieved = retrieve_cancelled(runs, executor, trainer, &mut record).await?;
}
Ok((record, status, retrieved))
}
async fn retrieve_cancelled<E: Executor, T: Trainer>(
runs: &Runs,
executor: &E,
trainer: &T,
record: &mut RunRecord,
) -> Result<bool, RunError> {
let local = runs.run_dir(&record.id)?;
let Err(error) = retrieve(
executor,
trainer,
&record.remote_dir,
&local,
Expect::Nothing,
)
.await
else {
return Ok(true);
};
cancelled_warning("retrieve the artifacts of", &record.id, &error);
record.message = Some(not_retrieved(None, &error));
if let Err(save_error) = runs.save(record) {
cancelled_warning("note the missing artifacts of", &record.id, &save_error);
}
Ok(false)
}
fn cancelled_warning(what: &str, id: &str, error: &dyn std::fmt::Display) {
tracing::warn!("cannot {what} cancelled run {id}: {error}");
}
#[cfg(test)]
mod tests {
use std::future::{Future, ready};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use super::*;
#[test]
fn an_unreachable_job_says_why_ssh_failed() {
let error = ExecError::Ssh(crate::exec::SshError::Disconnected);
assert_eq!(
unreachable_line(&error, 2),
format!(
"cannot reach the job (2/{MAX_FAILURES}): ssh failed: the connection was terminated"
)
);
}
use crate::exec::{FileDigest, JobCommand, MAX_TAIL_READ, Pid};
use crate::runs::RECORD_FILE;
use crate::train::Artifacts;
#[test]
fn outcomes_follow_the_exit_code_and_the_metrics() {
let mut summary = MetricsSummary::default();
let (state, message) = outcome(JobStatus::Exited(0), &summary, "runs/r1", true);
assert_eq!(state, RunState::Failed);
assert!(message.is_some_and(|message| message.contains("plugin was not loaded")));
assert_eq!(
outcome(JobStatus::Exited(0), &summary, "runs/r1", false),
(RunState::Succeeded, None),
"a job without metrics needs only its exit code"
);
summary.add(r#"{"event": "begin", "time": 1}"#);
assert_eq!(
outcome(JobStatus::Exited(0), &summary, "runs/r1", true),
(RunState::Succeeded, None)
);
assert_eq!(
outcome(JobStatus::Exited(2), &summary, "runs/r1", true),
(
RunState::Failed,
Some("the job exited with code 2 (see runs/r1/job.log)".to_string())
)
);
assert_eq!(
outcome(JobStatus::Cancelled, &summary, "runs/r1", true),
(RunState::Cancelled, None)
);
assert_eq!(
outcome(JobStatus::Lost, &summary, "runs/r1", false).0,
RunState::Failed
);
}
#[test]
fn the_download_error_is_added_to_the_message() {
let error = ExecError::Command {
action: "download",
message: "/w/r1 does not exist".to_string(),
};
assert_eq!(
not_retrieved(Some("the job exited with code 2".to_string()), &error),
"the job exited with code 2 (artifacts not retrieved: download failed: /w/r1 does not exist)"
);
assert_eq!(
not_retrieved(None, &error),
"artifacts not retrieved: download failed: /w/r1 does not exist"
);
}
const METRICS: &str = "{\"event\": \"begin\", \"time\": 1}\n";
const RUN_ID: &str = "20260922-143005-abcd";
#[derive(Debug, Clone)]
enum Probe {
Answers(Vec<u8>),
Fails,
Hangs,
}
struct Fake {
status: JobStatus,
metrics: String,
spawn_fails: bool,
claim_taken: bool,
download_fails: bool,
failing_read: Option<u32>,
break_on_download: Option<PathBuf>,
manifest: Vec<FileDigest>,
proof: String,
reads: AtomicU32,
cancels: AtomicU32,
probe: Probe,
probes: AtomicU32,
running_polls: u32,
polls: AtomicU32,
puts: std::sync::Mutex<Vec<(String, String)>>,
request_reads: RequestReads,
spawned: std::sync::Mutex<Vec<JobCommand>>,
}
impl Fake {
fn new(status: JobStatus) -> Self {
Self {
status,
metrics: METRICS.to_string(),
spawn_fails: false,
claim_taken: false,
download_fails: false,
failing_read: None,
break_on_download: None,
manifest: Vec::new(),
proof: String::new(),
reads: AtomicU32::new(0),
cancels: AtomicU32::new(0),
probe: Probe::Fails,
probes: AtomicU32::new(0),
running_polls: 0,
polls: AtomicU32::new(0),
puts: std::sync::Mutex::new(Vec::new()),
request_reads: RequestReads::Answered,
spawned: std::sync::Mutex::new(Vec::new()),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RequestReads {
Answered,
Hung,
}
fn broken(action: &'static str) -> ExecError {
ExecError::Command {
action,
message: "connection reset".to_string(),
}
}
fn job() -> Result<JobId, ExecError> {
Ok(JobId {
dir: format!("/w/{RUN_ID}"),
pid: Pid::new(42)?,
container: None,
})
}
impl Executor for Fake {
fn workdir(&self) -> &'static str {
"/w"
}
fn claim(
&self,
_dir: &str,
_owner: &str,
) -> impl Future<Output = Result<bool, ExecError>> + Send {
ready(Ok(!self.claim_taken))
}
fn upload(
&self,
_local: &Path,
_remote: &str,
_skip: &[String],
) -> impl Future<Output = Result<(), ExecError>> + Send {
ready(Ok(()))
}
fn spawn(
&self,
command: &JobCommand,
) -> impl Future<Output = Result<JobId, ExecError>> + Send {
if let Ok(mut spawned) = self.spawned.lock() {
spawned.push(command.clone());
}
ready(if self.spawn_fails {
Err(broken("spawn"))
} else {
job()
})
}
fn read_from(
&self,
path: &str,
offset: u64,
limit: u64,
) -> impl Future<Output = Result<Vec<u8>, ExecError>> + Send {
use futures::future::{Either, pending};
if path.ends_with(SNAPSHOT_FILE) {
return Either::Right(ready(Ok(self.proof.clone().into_bytes())));
}
if path.ends_with(crate::train::SNAPSHOT_REQUEST) {
if self.request_reads == RequestReads::Hung {
return Either::Left(pending());
}
let request = self.puts.lock().ok().and_then(|puts| {
puts.iter()
.rfind(|(put, _)| put == path)
.map(|(_, content)| content.clone().into_bytes())
});
return Either::Right(ready(Ok(request.unwrap_or_default())));
}
let read = self.reads.fetch_add(1, Ordering::SeqCst);
let start = usize::try_from(offset).unwrap_or(self.metrics.len());
let mut bytes = self
.metrics
.as_bytes()
.get(start..)
.unwrap_or_default()
.to_vec();
bytes.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
Either::Right(ready(if self.failing_read == Some(read) {
Err(broken("read"))
} else {
Ok(bytes)
}))
}
fn status(
&self,
_job: &JobId,
) -> impl Future<Output = Result<JobStatus, ExecError>> + Send {
let poll = self.polls.fetch_add(1, Ordering::SeqCst);
ready(Ok(if poll < self.running_polls {
JobStatus::Running
} else {
self.status
}))
}
fn cancel(&self, _job: &JobId) -> impl Future<Output = Result<(), ExecError>> + Send {
self.cancels.fetch_add(1, Ordering::SeqCst);
ready(Ok(()))
}
fn download(
&self,
_remote: &str,
_local: &Path,
_entries: &[String],
_exclude: &[String],
) -> impl Future<Output = Result<(), ExecError>> + Send {
if let Some(dir) = &self.break_on_download {
break_saves_in(dir).ok();
}
ready(if self.download_fails {
Err(broken("download"))
} else {
Ok(())
})
}
fn manifest(
&self,
_remote: &str,
entries: &[String],
exclude: &[String],
) -> impl Future<Output = Result<Vec<FileDigest>, ExecError>> + Send {
let listed = self
.manifest
.iter()
.filter(|file| {
entries.iter().any(|entry| {
file.path == *entry || file.path.starts_with(&format!("{entry}/"))
}) && !file.path.split('/').any(|part| {
exclude
.iter()
.any(|pattern| crate::exec::glob_match(pattern, part))
})
})
.cloned()
.collect();
ready(Ok(listed))
}
fn probe(&self, _script: &str) -> impl Future<Output = Result<Vec<u8>, ExecError>> + Send {
self.probes.fetch_add(1, Ordering::SeqCst);
let probe = self.probe.clone();
async move {
match probe {
Probe::Answers(output) => Ok(output),
Probe::Fails => Err(broken("probe")),
Probe::Hangs => std::future::pending().await,
}
}
}
fn put_file(
&self,
path: &str,
content: &str,
) -> impl Future<Output = Result<(), ExecError>> + Send {
if let Ok(mut puts) = self.puts.lock() {
puts.push((path.to_string(), content.to_string()));
}
ready(Ok(()))
}
}
struct NoFiles;
impl Trainer for NoFiles {
fn prepare(&self, _run_dir: &Path, _root: &str) -> Result<(), TrainError> {
Ok(())
}
fn commands(&self) -> Vec<Vec<String>> {
Vec::new()
}
fn env(&self, _root: &str) -> Vec<(String, String)> {
Vec::new()
}
fn metrics_file(&self) -> &'static str {
"metrics.jsonl"
}
fn artifacts(&self) -> Artifacts {
Artifacts {
entries: Vec::new(),
exclude: Vec::new(),
required: None,
}
}
}
struct WithOutput;
impl Trainer for WithOutput {
fn prepare(&self, _run_dir: &Path, _root: &str) -> Result<(), TrainError> {
Ok(())
}
fn commands(&self) -> Vec<Vec<String>> {
Vec::new()
}
fn env(&self, _root: &str) -> Vec<(String, String)> {
Vec::new()
}
fn metrics_file(&self) -> &'static str {
"metrics.jsonl"
}
fn artifacts(&self) -> Artifacts {
Artifacts {
entries: vec!["output".to_string()],
exclude: vec!["checkpoint-*".to_string()],
required: Some("output".to_string()),
}
}
fn stop_marker(&self) -> Option<&'static str> {
Some(SNAPSHOT_FILE)
}
}
fn running() -> Result<RunRecord, ExecError> {
Ok(RunRecord {
id: RUN_ID.to_string(),
target: "box".to_string(),
created: "2026-09-22T14:30:05Z".to_string(),
remote_dir: format!("/w/{RUN_ID}"),
job: Some(job()?),
state: RunState::Running,
message: None,
snapshot: None,
resumed_from: None,
snapshots: true,
})
}
fn ctx<'a>(runs: &'a Runs, executor: &'a Fake, bus: &'a EventBus) -> RunCtx<'a, Fake> {
RunCtx {
runs,
executor,
bus,
poll: Duration::from_millis(1),
}
}
fn break_saves(runs: &Runs, id: &str) -> Result<(), Box<dyn std::error::Error>> {
break_saves_in(&runs.run_dir(id)?)?;
Ok(())
}
fn break_saves_in(dir: &Path) -> std::io::Result<()> {
let record = dir.join(RECORD_FILE);
if record.is_file() {
std::fs::rename(&record, dir.join(SAVED_BEFORE))?;
}
std::fs::create_dir(record)
}
const SAVED_BEFORE: &str = "run.json.before";
#[tokio::test]
async fn a_successful_run_whose_artifacts_cannot_be_retrieved_stays_running()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake {
download_fails: true,
..Fake::new(JobStatus::Exited(0))
};
let record = running()?;
runs.save(&record)?;
let error = watch(&ctx(&runs, &fake, &bus), &NoFiles, record.clone())
.await
.err()
.ok_or("the watch succeeded")?;
assert!(error.to_string().contains("connection reset"), "{error}");
assert_eq!(runs.load(&record.id)?, record);
Ok(())
}
#[tokio::test]
async fn a_failed_compare_names_its_own_job_log() -> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let compares = Runs::new(project.path()).compares(RUN_ID)?;
let bus = EventBus::new();
let fake = Fake::new(JobStatus::Exited(1));
let record = RunRecord {
id: "compare_20261006-120000".to_string(),
..running()?
};
compares.save(&record)?;
let outcome = watch(&ctx(&compares, &fake, &bus), &NoFiles, record).await?;
assert_eq!(outcome.record.state, RunState::Failed);
assert_eq!(
outcome.record.message.as_deref(),
Some(
"the job exited with code 1 \
(see runs/20260922-143005-abcd/compares/compare_20261006-120000/job.log)"
)
);
Ok(())
}
#[tokio::test]
async fn the_last_read_after_the_end_is_retried() -> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake {
failing_read: Some(1),
..Fake::new(JobStatus::Exited(0))
};
let record = running()?;
runs.save(&record)?;
let outcome = watch(&ctx(&runs, &fake, &bus), &NoFiles, record).await?;
assert_eq!(outcome.record.state, RunState::Succeeded);
assert_eq!(outcome.summary.lines, 1);
assert_eq!(fake.reads.load(Ordering::SeqCst), 3);
Ok(())
}
#[tokio::test]
async fn the_target_is_sampled_while_the_job_is_followed()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let mut events = bus.subscribe();
let df = "@df.run\nFilesystem 1024-blocks Used Available Capacity Mounted on\n\
/dev/sda1 100 60 40 60% /workspace\n";
let fake = Fake {
probe: Probe::Answers(df.as_bytes().to_vec()),
running_polls: 3,
..Fake::new(JobStatus::Exited(0))
};
let record = running()?;
runs.save(&record)?;
let outcome = watch(&ctx(&runs, &fake, &bus), &NoFiles, record).await?;
assert_eq!(outcome.record.state, RunState::Succeeded);
assert_eq!(fake.probes.load(Ordering::SeqCst), 1);
let mut samples = Vec::new();
while let Ok(event) = events.try_recv() {
if let Event::System(sample) = event {
samples.push(sample);
}
}
let [sample] = samples.as_slice() else {
return Err(format!("{samples:?}").into());
};
let disk = sample.run_disk().ok_or("no disk")?;
assert_eq!(
(disk.mount.as_str(), disk.used_bytes),
("/workspace", 60 * 1024)
);
Ok(())
}
async fn watch_long(fake: Fake) -> Result<(Outcome, Fake), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake {
running_polls: 80,
..fake
};
let record = running()?;
runs.save(&record)?;
let ctx = RunCtx {
poll: Duration::from_secs(1),
..ctx(&runs, &fake, &bus)
};
let outcome = watch(&ctx, &NoFiles, record).await?;
Ok((outcome, fake))
}
#[tokio::test(start_paused = true)]
async fn failing_samples_never_count_against_the_follow()
-> Result<(), Box<dyn std::error::Error>> {
let (outcome, fake) = watch_long(Fake::new(JobStatus::Exited(0))).await?;
assert_eq!(outcome.record.state, RunState::Succeeded);
let probes = fake.probes.load(Ordering::SeqCst);
assert!(probes > MAX_FAILURES + 2, "{probes}");
Ok(())
}
#[tokio::test(start_paused = true)]
async fn a_sample_that_never_answers_never_holds_the_follow()
-> Result<(), Box<dyn std::error::Error>> {
let fake = Fake {
probe: Probe::Hangs,
..Fake::new(JobStatus::Exited(0))
};
let (outcome, fake) = watch_long(fake).await?;
assert_eq!(outcome.record.state, RunState::Succeeded);
let probes = fake.probes.load(Ordering::SeqCst);
assert!((2..=5).contains(&probes), "{probes}");
Ok(())
}
#[tokio::test]
async fn the_final_drain_reads_the_whole_metrics_file() -> Result<(), Box<dyn std::error::Error>>
{
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let line = r#"{"event": "log", "time": 1, "step": 7, "loss": 1.0}"#;
let count = 2 * usize::try_from(MAX_TAIL_READ)? / (line.len() + 1) + 1;
let mut metrics = String::with_capacity(count * (line.len() + 1));
for _ in 0..count {
metrics.push_str(line);
metrics.push('\n');
}
let fake = Fake {
metrics,
..Fake::new(JobStatus::Exited(0))
};
let record = running()?;
runs.save(&record)?;
let outcome = watch(&ctx(&runs, &fake, &bus), &NoFiles, record).await?;
assert_eq!(outcome.record.state, RunState::Succeeded);
assert_eq!(outcome.summary.lines, count);
Ok(())
}
#[tokio::test]
async fn a_run_directory_another_checkout_owns_on_the_target_is_skipped()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let target = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let executor = crate::exec::LocalExecutor::new(target.path())?;
let base = "demo_20260930-120000";
let now = SystemTime::UNIX_EPOCH;
assert!(
executor
.claim(&format!("{}/{base}", executor.workdir()), "elsewhere")
.await?
);
let record = create_named(&runs, &executor, base, "box", now).await?;
assert_eq!(record.id, format!("{base}_2"));
assert_eq!(
record.remote_dir,
format!("{}/{base}_2", executor.workdir())
);
assert!(
Path::new(&record.remote_dir)
.join(crate::exec::CLAIM_FILE)
.is_file()
);
assert!(!runs.dir().join(base).exists());
assert_eq!(runs.list()?, vec![record]);
Ok(())
}
#[tokio::test]
async fn a_local_target_claims_the_run_directory_it_shares_with_runs()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let executor = crate::exec::LocalExecutor::new(runs.dir())?;
let first = create_on(&runs, &executor, "demo", "here").await?;
let second = create_on(&runs, &executor, "demo", "here").await?;
assert_ne!(first.id, second.id);
for record in [&first, &second] {
assert_eq!(runs.load(&record.id)?, *record);
assert!(
runs.run_dir(&record.id)?
.join(crate::exec::CLAIM_FILE)
.is_file()
);
}
Ok(())
}
#[tokio::test]
async fn a_run_whose_directory_is_taken_on_its_pod_fails_before_any_copy()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake {
claim_taken: true,
..Fake::new(JobStatus::Running)
};
let mut record = create(&runs, "demo", fake.workdir(), "cloud")?;
let result = reserve(&ctx(&runs, &fake, &bus), &mut record, "mine").await;
assert!(matches!(&result, Err(RunError::Taken(dir)) if *dir == record.remote_dir));
let saved = runs.load(&record.id)?;
assert_eq!(saved.state, RunState::Failed);
assert!(
saved
.message
.as_deref()
.is_some_and(|message| message.contains("belongs to another run")),
"{saved:?}"
);
let mut free = create(&runs, "demo", "/w", "cloud")?;
reserve(
&ctx(&runs, &Fake::new(JobStatus::Running), &bus),
&mut free,
"mine",
)
.await?;
assert_eq!(runs.load(&free.id)?.state, RunState::Preparing);
Ok(())
}
#[tokio::test]
async fn reserve_accepts_the_runs_own_claim_and_refuses_another()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let target = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let executor = crate::exec::LocalExecutor::new(target.path())?;
let ctx = RunCtx {
runs: &runs,
executor: &executor,
bus: &bus,
poll: Duration::from_millis(1),
};
let mut ours = create(&runs, "demo", executor.workdir(), "cloud")?;
assert!(
executor
.claim(&ours.remote_dir, "ssh-ed25519 AAAAours")
.await?
);
reserve(&ctx, &mut ours, "ssh-ed25519 AAAAours").await?;
assert_eq!(runs.load(&ours.id)?.state, RunState::Preparing);
let mut theirs = create(&runs, "demo", executor.workdir(), "cloud")?;
assert!(
executor
.claim(&theirs.remote_dir, "ssh-ed25519 AAAAtheirs")
.await?
);
let refused = reserve(&ctx, &mut theirs, "ssh-ed25519 AAAAours").await;
assert!(matches!(refused, Err(RunError::Taken(_))), "{refused:?}");
assert_eq!(runs.load(&theirs.id)?.state, RunState::Failed);
assert_eq!(
std::fs::read_to_string(Path::new(&theirs.remote_dir).join(crate::exec::CLAIM_FILE))?,
"ssh-ed25519 AAAAtheirs\n"
);
Ok(())
}
#[test]
fn runs_created_in_the_same_second_keep_their_own_records()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let first = create(&runs, "Demo Project", "/w", "box")?;
let second = create(&runs, "Demo Project", "/w", "other")?;
assert!(first.id.starts_with("demo_project_"), "{}", first.id);
assert_ne!(first.id, second.id);
assert_eq!(second.remote_dir, format!("/w/{}", second.id));
assert_eq!(runs.load(&first.id)?.target, "box");
assert_eq!(runs.load(&second.id)?.target, "other");
Ok(())
}
#[tokio::test]
async fn a_job_whose_run_cannot_be_recorded_is_cancelled()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake::new(JobStatus::Running);
let created = create(&runs, "demo", fake.workdir(), "box")?;
break_saves(&runs, &created.id)?;
let launch = Launch {
runtime: &JobRuntime::Native {
venv: None,
env_file: None,
},
secrets: Vec::new(),
};
let result = start(&ctx(&runs, &fake, &bus), &NoFiles, launch, created).await;
assert!(matches!(result, Err(RunError::Runs(_))), "{result:?}");
assert_eq!(fake.cancels.load(Ordering::SeqCst), 1);
Ok(())
}
#[tokio::test]
async fn a_mounted_job_sees_its_parent_but_keeps_its_own_directory()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path()).exports("r1")?;
let bus = EventBus::new();
let fake = Fake::new(JobStatus::Running);
let runtime = JobRuntime::Container {
engine: crate::config::Engine::Docker,
image: "img:1".into(),
};
let mount = format!("{}/r1", fake.workdir());
let created = create(&runs, "export", &format!("{mount}/exports"), "box")?;
let job_dir = created.remote_dir.clone();
let launch = Launch {
runtime: &runtime,
secrets: Vec::new(),
};
let started =
start_mounted(&ctx(&runs, &fake, &bus), &NoFiles, launch, created, &mount).await?;
assert_eq!(started.remote_dir, job_dir);
let spawned = fake.spawned.lock().map_err(|_| "poisoned")?.clone();
let [job] = spawned.as_slice() else {
return Err(format!("{spawned:?}").into());
};
assert_eq!(job.dir, job_dir);
assert!(
job.script
.contains(&format!("-v '{mount}:/workspace/run' ")),
"{}",
job.script
);
let export = crate::export::ExportJob::in_place("r1", "Q8_0", "output", "x".into());
let created = create(&runs, "export", &format!("{mount}/exports"), "box")?;
let id = created.id.clone();
let launch = Launch {
runtime: &runtime,
secrets: Vec::new(),
};
start_mounted(&ctx(&runs, &fake, &bus), &export, launch, created, &mount).await?;
let spawned = fake.spawned.lock().map_err(|_| "poisoned")?.clone();
let script = &spawned.last().ok_or("not spawned")?.script;
let file = format!("/workspace/run/exports/{id}/metrics.jsonl");
assert!(script.contains(&file), "{script}");
assert!(script.contains("export"), "{script}");
let outside = create(&runs, "export", "/elsewhere", "box")?;
let launch = Launch {
runtime: &runtime,
secrets: Vec::new(),
};
let refused =
start_mounted(&ctx(&runs, &fake, &bus), &NoFiles, launch, outside, &mount).await;
assert!(matches!(refused, Err(RunError::Exec(_))), "{refused:?}");
Ok(())
}
#[tokio::test]
async fn a_started_run_is_recorded_as_able_to_snapshot()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake::new(JobStatus::Running);
let runtime = JobRuntime::Native {
venv: None,
env_file: None,
};
let launch = || Launch {
runtime: &runtime,
secrets: Vec::new(),
};
let created = create(&runs, "demo", fake.workdir(), "box")?;
assert!(!created.snapshots, "no job yet");
let started = start(&ctx(&runs, &fake, &bus), &WithOutput, launch(), created).await?;
assert!(started.snapshots);
assert!(runs.load(&started.id)?.snapshots);
let created = create(&runs, "other", fake.workdir(), "box")?;
let started = start(&ctx(&runs, &fake, &bus), &NoFiles, launch(), created).await?;
assert!(!started.snapshots);
Ok(())
}
#[tokio::test]
async fn a_failed_start_returns_its_own_error_when_it_cannot_be_recorded()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake {
spawn_fails: true,
..Fake::new(JobStatus::Running)
};
let created = create(&runs, "demo", fake.workdir(), "box")?;
break_saves(&runs, &created.id)?;
let launch = Launch {
runtime: &JobRuntime::Native {
venv: None,
env_file: None,
},
secrets: Vec::new(),
};
let result = start(&ctx(&runs, &fake, &bus), &NoFiles, launch, created).await;
assert!(
matches!(
&result,
Err(RunError::Exec(ExecError::Command {
action: "spawn",
..
}))
),
"{result:?}"
);
assert_eq!(fake.cancels.load(Ordering::SeqCst), 0);
Ok(())
}
#[tokio::test]
async fn a_cancelled_run_keeps_cancelled_when_its_artifacts_cannot_be_retrieved()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let fake = Fake {
download_fails: true,
..Fake::new(JobStatus::Cancelled)
};
let record = running()?;
runs.save(&record)?;
let (cancelled, status, retrieved) = cancel(&runs, &fake, &NoFiles, record).await?;
assert!(!retrieved);
assert_eq!(status, JobStatus::Cancelled);
assert_eq!(cancelled.state, RunState::Cancelled);
assert_eq!(
cancelled.message.as_deref(),
Some("artifacts not retrieved: download failed: connection reset")
);
assert_eq!(runs.load(&cancelled.id)?, cancelled);
Ok(())
}
#[tokio::test]
async fn a_cancel_is_reported_even_when_its_note_cannot_be_saved()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let record = running()?;
runs.save(&record)?;
let fake = Fake {
download_fails: true,
break_on_download: Some(runs.run_dir(RUN_ID)?),
..Fake::new(JobStatus::Cancelled)
};
let (cancelled, status, retrieved) = cancel(&runs, &fake, &NoFiles, record).await?;
assert!(!retrieved);
assert_eq!(status, JobStatus::Cancelled);
assert_eq!(cancelled.state, RunState::Cancelled);
assert!(
cancelled
.message
.as_deref()
.is_some_and(|message| message.starts_with("artifacts not retrieved: ")),
"{:?}",
cancelled.message
);
let saved: RunRecord =
serde_json::from_slice(&std::fs::read(runs.run_dir(RUN_ID)?.join(SAVED_BEFORE))?)?;
assert_eq!(saved.state, RunState::Cancelled);
assert_eq!(saved.message, None);
Ok(())
}
#[tokio::test]
async fn a_job_that_ended_before_the_first_poll_reports_only_its_end()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let mut receiver = bus.subscribe();
let fake = Fake::new(JobStatus::Exited(0));
let record = running()?;
runs.save(&record)?;
watch(&ctx(&runs, &fake, &bus), &NoFiles, record).await?;
let mut statuses = Vec::new();
while let Ok(event) = receiver.try_recv() {
if let Event::JobStatus(status) = event {
statuses.push(status);
}
}
assert_eq!(statuses, vec![JobStatus::Exited(0)]);
Ok(())
}
const W: &str = "50e721e49c013f00c62cf59f2163542a9d8df02464efeb615d31051b0fddc326";
fn listed(path: &str) -> FileDigest {
FileDigest {
path: path.to_string(),
sha256: W.to_string(),
}
}
fn downloaded(
runs: &Runs,
path: &str,
content: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let file = runs.run_dir(RUN_ID)?.join(path);
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(file, content)?;
Ok(())
}
async fn watch_with(
manifest: Vec<FileDigest>,
files: &[(&str, &str)],
) -> Result<(Runs, tempfile::TempDir, Result<Outcome, RunError>), Box<dyn std::error::Error>>
{
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake {
manifest,
..Fake::new(JobStatus::Exited(0))
};
runs.save(&running()?)?;
for (path, content) in files {
downloaded(&runs, path, content)?;
}
let result = watch(&ctx(&runs, &fake, &bus), &WithOutput, running()?).await;
Ok((runs, project, result))
}
#[tokio::test]
async fn verified_files_make_a_retrieved_success() -> Result<(), Box<dyn std::error::Error>> {
let (_, _project, result) = watch_with(
vec![listed("output/adapter.bin"), listed("job.log")],
&[("output/adapter.bin", "w"), ("job.log", "w")],
)
.await?;
let outcome = result?;
assert_eq!(outcome.record.state, RunState::Succeeded);
assert!(outcome.retrieved);
Ok(())
}
#[tokio::test]
async fn a_missing_different_or_extra_file_leaves_a_success_running()
-> Result<(), Box<dyn std::error::Error>> {
let cases = [
(
&[("job.log", "w")][..],
"verify failed: \"output/adapter.bin\" is missing locally",
),
(
&[("output/adapter.bin", "x"), ("job.log", "w")][..],
"verify failed: \"output/adapter.bin\" differs from the target (SHA-256 mismatch)",
),
(
&[
("output/adapter.bin", "w"),
("job.log", "w"),
("output/extra.bin", "w"),
][..],
"verify failed: \"output/extra.bin\" is not on the target",
),
];
for (files, expected) in cases {
let (runs, _project, result) =
watch_with(vec![listed("output/adapter.bin"), listed("job.log")], files).await?;
let error = result.err().ok_or("the watch succeeded")?;
assert_eq!(error.to_string(), expected, "{files:?}");
assert_eq!(runs.load(RUN_ID)?.state, RunState::Running);
}
Ok(())
}
#[tokio::test]
async fn a_success_without_output_is_a_permanent_failure()
-> Result<(), Box<dyn std::error::Error>> {
let (runs, _project, result) =
watch_with(vec![listed("job.log")], &[("job.log", "w")]).await?;
let outcome = result?;
assert_eq!(outcome.record.state, RunState::Failed);
assert!(outcome.retrieved);
assert_eq!(
outcome.record.message.as_deref(),
Some(
format!("the job succeeded but left no output (see runs/{RUN_ID}/job.log)")
.as_str()
)
);
assert_eq!(runs.load(RUN_ID)?, outcome.record);
Ok(())
}
#[tokio::test]
async fn a_permanent_failure_without_output_is_retrieved_on_every_path()
-> Result<(), Box<dyn std::error::Error>> {
let (runs, _project, result) =
watch_with(vec![listed("job.log")], &[("job.log", "w")]).await?;
let outcome = result?;
assert_eq!(outcome.record.state, RunState::Failed);
assert!(outcome.retrieved);
let bus = EventBus::new();
let fake = Fake {
manifest: vec![listed("job.log")],
..Fake::new(JobStatus::Exited(0))
};
let again = watch(
&ctx(&runs, &fake, &bus),
&WithOutput,
outcome.record.clone(),
)
.await?;
assert!(again.retrieved);
let (_, collected) = collect(&ctx(&runs, &fake, &bus), &WithOutput, outcome.record).await?;
assert!(collected);
Ok(())
}
#[tokio::test]
async fn a_failed_run_whose_verification_fails_gets_the_note()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake {
manifest: vec![listed("job.log")],
..Fake::new(JobStatus::Exited(2))
};
runs.save(&running()?)?;
let outcome = watch(&ctx(&runs, &fake, &bus), &NoFiles, running()?).await?;
assert_eq!(outcome.record.state, RunState::Failed);
assert!(!outcome.retrieved);
let message = outcome.record.message.clone().unwrap_or_default();
assert!(message.contains("the job exited with code 2"), "{message}");
assert!(
message.contains(
"(artifacts not retrieved: verify failed: \"job.log\" is missing locally)"
),
"{message}"
);
assert!(artifacts_missing(&outcome.record));
assert_eq!(runs.load(RUN_ID)?, outcome.record);
Ok(())
}
#[tokio::test]
async fn a_final_record_is_retrieved_only_without_a_note_and_with_a_local_job_log()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake::new(JobStatus::Cancelled);
let mut record = running()?;
record.state = RunState::Cancelled;
runs.save(&record)?;
let outcome = watch(&ctx(&runs, &fake, &bus), &NoFiles, record.clone()).await?;
assert!(!outcome.retrieved);
downloaded(&runs, JOB_LOG, "log")?;
let outcome = watch(&ctx(&runs, &fake, &bus), &NoFiles, record).await?;
assert!(outcome.retrieved);
Ok(())
}
#[tokio::test]
async fn a_cancelled_run_is_retrieved_when_nothing_prevents_it()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let fake = Fake::new(JobStatus::Cancelled);
let record = running()?;
runs.save(&record)?;
let (cancelled, status, retrieved) = cancel(&runs, &fake, &NoFiles, record).await?;
assert_eq!(status, JobStatus::Cancelled);
assert_eq!(cancelled.state, RunState::Cancelled);
assert!(retrieved);
Ok(())
}
#[tokio::test]
async fn collect_refuses_a_run_that_has_not_ended() -> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake::new(JobStatus::Running);
let record = running()?;
runs.save(&record)?;
let error = collect(&ctx(&runs, &fake, &bus), &NoFiles, record)
.await
.err()
.ok_or("collect succeeded")?;
assert!(matches!(error, RunError::NotEnded(_)), "{error:?}");
Ok(())
}
#[tokio::test]
async fn collect_retrieves_again_and_clears_the_note() -> Result<(), Box<dyn std::error::Error>>
{
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let mut record = running()?;
record.state = RunState::Failed;
record.message = Some(
"the job exited with code 2 (artifacts not retrieved: download failed: reset)"
.to_string(),
);
runs.save(&record)?;
let broken = Fake {
download_fails: true,
..Fake::new(JobStatus::Exited(2))
};
let (kept, retrieved) = collect(&ctx(&runs, &broken, &bus), &NoFiles, record).await?;
assert!(!retrieved);
assert!(artifacts_missing(&kept));
let fake = Fake {
manifest: vec![listed("job.log")],
..Fake::new(JobStatus::Exited(2))
};
downloaded(&runs, "job.log", "w")?;
let (collected, retrieved) = collect(&ctx(&runs, &fake, &bus), &NoFiles, kept).await?;
assert!(retrieved);
assert_eq!(
collected.message.as_deref(),
Some("the job exited with code 2")
);
assert_eq!(runs.load(RUN_ID)?, collected);
Ok(())
}
const PROOF: &str =
r#"{"checkpoint": "output/checkpoint-3", "step": 3, "time": 1.0, "reason": "deadline"}"#;
fn stopped_manifest() -> Vec<FileDigest> {
vec![
listed("job.log"),
listed("output/adapter.bin"),
listed("output/checkpoint-3/optimizer.pt"),
listed("snapshot.json"),
]
}
async fn watch_stopped(
proof: &str,
manifest: Vec<FileDigest>,
files: &[&str],
) -> Result<(Runs, tempfile::TempDir, Result<Outcome, RunError>), Box<dyn std::error::Error>>
{
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake {
manifest,
proof: proof.to_string(),
..Fake::new(JobStatus::Exited(0))
};
runs.save(&running()?)?;
for path in files {
downloaded(&runs, path, "w")?;
}
let result = watch(&ctx(&runs, &fake, &bus), &WithOutput, running()?).await;
Ok((runs, project, result))
}
#[tokio::test]
async fn a_job_that_left_a_proof_is_stopped_with_its_checkpoint()
-> Result<(), Box<dyn std::error::Error>> {
let files = [
"job.log",
"output/adapter.bin",
"output/checkpoint-3/optimizer.pt",
"snapshot.json",
];
let (runs, _project, result) = watch_stopped(PROOF, stopped_manifest(), &files).await?;
let outcome = result?;
assert_eq!(outcome.record.state, RunState::Stopped);
assert!(outcome.retrieved);
assert_eq!(outcome.record.message, None);
assert_eq!(
outcome.record.snapshot,
Some(Snapshot {
checkpoint: "output/checkpoint-3".into(),
step: 3,
reason: crate::runs::SnapshotReason::Deadline,
})
);
assert_eq!(runs.load(RUN_ID)?, outcome.record);
let bus = EventBus::new();
let fake = Fake::new(JobStatus::Exited(0));
let again = watch(
&ctx(&runs, &fake, &bus),
&WithOutput,
outcome.record.clone(),
)
.await?;
assert!(again.retrieved);
std::fs::remove_dir_all(runs.run_dir(RUN_ID)?.join("output/checkpoint-3"))?;
let again = watch(&ctx(&runs, &fake, &bus), &WithOutput, outcome.record).await?;
assert!(!again.retrieved);
Ok(())
}
#[tokio::test]
async fn a_proof_at_the_final_step_still_records_the_run_stopped()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake {
manifest: stopped_manifest(),
proof: PROOF.to_string(),
metrics: "{\"event\": \"begin\", \"time\": 1, \"max_steps\": 3}\n\
{\"event\": \"log\", \"time\": 2, \"step\": 3, \"max_steps\": 3, \"loss\": 1.0}\n"
.to_string(),
..Fake::new(JobStatus::Exited(0))
};
runs.save(&running()?)?;
for path in [
"job.log",
"output/adapter.bin",
"output/checkpoint-3/optimizer.pt",
"snapshot.json",
] {
downloaded(&runs, path, "w")?;
}
let outcome = watch(&ctx(&runs, &fake, &bus), &WithOutput, running()?).await?;
assert_eq!(outcome.summary.lines, 2);
assert_eq!(outcome.record.state, RunState::Stopped);
assert_eq!(
outcome.record.snapshot.map(|snapshot| snapshot.step),
Some(3)
);
assert!(outcome.retrieved);
Ok(())
}
#[tokio::test]
async fn a_job_cancelled_after_its_proof_is_stopped() -> Result<(), Box<dyn std::error::Error>>
{
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let fake = Fake {
manifest: stopped_manifest(),
proof: PROOF.to_string(),
..Fake::new(JobStatus::Cancelled)
};
runs.save(&running()?)?;
for path in [
"job.log",
"output/adapter.bin",
"output/checkpoint-3/optimizer.pt",
"snapshot.json",
] {
downloaded(&runs, path, "w")?;
}
let outcome = watch(&ctx(&runs, &fake, &bus), &WithOutput, running()?).await?;
assert_eq!(outcome.record.state, RunState::Stopped);
assert!(outcome.retrieved);
Ok(())
}
#[tokio::test]
async fn a_checkpoint_not_retrieved_leaves_a_stopped_run_running()
-> Result<(), Box<dyn std::error::Error>> {
let files = ["job.log", "output/adapter.bin", "snapshot.json"];
let (runs, _project, result) = watch_stopped(PROOF, stopped_manifest(), &files).await?;
let error = result.err().ok_or("the watch succeeded")?;
assert_eq!(
error.to_string(),
"verify failed: \"output/checkpoint-3/optimizer.pt\" is missing locally"
);
assert_eq!(runs.load(RUN_ID)?.state, RunState::Running);
Ok(())
}
#[tokio::test]
async fn a_snapshot_without_a_checkpoint_or_a_valid_proof_is_a_failure()
-> Result<(), Box<dyn std::error::Error>> {
let manifest = vec![
listed("job.log"),
listed("output/adapter.bin"),
listed("snapshot.json"),
];
let files = ["job.log", "output/adapter.bin", "snapshot.json"];
let (_, _project, result) = watch_stopped(PROOF, manifest.clone(), &files).await?;
let outcome = result?;
assert_eq!(outcome.record.state, RunState::Failed);
assert_eq!(outcome.record.snapshot, None);
assert_eq!(
outcome.record.message.as_deref(),
Some(
format!(
"the job stopped with a snapshot but left no file in output/checkpoint-3 \
(see runs/{RUN_ID}/job.log)"
)
.as_str()
)
);
let (_, _project, result) =
watch_stopped(r#"{"checkpoint": "../x", "step": 3}"#, manifest, &files).await?;
let outcome = result?;
assert_eq!(outcome.record.state, RunState::Failed);
assert_eq!(
outcome.record.message.as_deref(),
Some(
"the job stopped with a snapshot, but snapshot.json names an invalid checkpoint path"
)
);
Ok(())
}
#[tokio::test]
async fn collect_retrieves_the_checkpoint_of_a_stopped_run_again()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let bus = EventBus::new();
let mut record = running()?;
record.state = RunState::Stopped;
record.snapshot = Some(Snapshot {
checkpoint: "output/checkpoint-3".into(),
step: 3,
reason: crate::runs::SnapshotReason::Requested,
});
runs.save(&record)?;
let fake = Fake {
manifest: stopped_manifest(),
..Fake::new(JobStatus::Exited(0))
};
for path in ["job.log", "output/adapter.bin", "snapshot.json"] {
downloaded(&runs, path, "w")?;
}
let (_, retrieved) = collect(&ctx(&runs, &fake, &bus), &WithOutput, record.clone()).await?;
assert!(!retrieved, "the checkpoint is missing locally");
downloaded(&runs, "output/checkpoint-3/optimizer.pt", "w")?;
let (collected, retrieved) = collect(&ctx(&runs, &fake, &bus), &WithOutput, record).await?;
assert!(retrieved);
assert_eq!(collected.state, RunState::Stopped);
Ok(())
}
#[tokio::test]
async fn a_snapshot_request_carries_its_reason_to_a_running_run_only()
-> Result<(), Box<dyn std::error::Error>> {
use crate::runs::{SnapshotReason, request_snapshot};
let fake = Fake::new(JobStatus::Running);
let record = running()?;
request_snapshot(&fake, &record, SnapshotReason::Cost).await?;
let puts = fake.puts.lock().map_err(|_| "poisoned")?.clone();
assert_eq!(
puts,
vec![(format!("/w/{RUN_ID}/snapshot.request"), "cost".to_string())]
);
let mut ended = record.clone();
ended.state = RunState::Succeeded;
let refused = request_snapshot(&fake, &ended, SnapshotReason::Requested).await;
assert!(
matches!(refused, Err(RunError::NotRunning(..))),
"{refused:?}"
);
let mut preparing = record;
preparing.job = None;
let refused = request_snapshot(&fake, &preparing, SnapshotReason::Requested).await;
assert!(
matches!(refused, Err(RunError::NotStarted(_))),
"{refused:?}"
);
Ok(())
}
#[tokio::test]
async fn a_run_started_before_snapshots_is_never_asked_for_one()
-> Result<(), Box<dyn std::error::Error>> {
use crate::runs::{SnapshotReason, request_snapshot};
let fake = Fake::new(JobStatus::Running);
let mut old = running()?;
old.snapshots = false;
let refused = request_snapshot(&fake, &old, SnapshotReason::Disk).await;
let Err(error @ RunError::NoSnapshots(_)) = refused else {
return Err(format!("{refused:?}").into());
};
assert_eq!(
error.to_string(),
format!(
"run {RUN_ID} was started by an overbrainer older than 0.5.0: its job cannot \
save a snapshot; cancel it with `overbrainer train cancel {RUN_ID}`, or let it \
finish"
)
);
assert!(fake.puts.lock().map_err(|_| "poisoned")?.is_empty());
Ok(())
}
#[tokio::test]
async fn a_stop_with_no_snapshot_in_time_cancels_the_job()
-> Result<(), Box<dyn std::error::Error>> {
use crate::runs::{StopLimits, with_stop_fallback};
let job = job()?;
let limits = StopLimits {
proof: Duration::from_millis(1),
end: Duration::from_millis(50),
};
for (proof, status, lasts, cancels) in [
("", JobStatus::Running, 200, 1),
(PROOF, JobStatus::Running, 200, 1),
(PROOF, JobStatus::Running, 20, 0),
("", JobStatus::Exited(0), 200, 0),
] {
let fake = Fake {
proof: proof.to_string(),
..Fake::new(status)
};
let flow = tokio::time::sleep(Duration::from_millis(lasts));
with_stop_fallback(&fake, &job, limits, flow).await;
assert_eq!(
fake.cancels.load(Ordering::SeqCst),
cancels,
"{proof:?} {status:?} {lasts}"
);
}
Ok(())
}
#[tokio::test(start_paused = true)]
async fn a_follow_cancels_a_job_only_from_a_request_it_sees()
-> Result<(), Box<dyn std::error::Error>> {
use crate::runs::{SnapshotReason, StopLimits, with_request_watch};
let run = running()?;
let request = format!("{}/{}", run.remote_dir, crate::train::SNAPSHOT_REQUEST);
let limits = StopLimits {
proof: Duration::from_secs(100),
end: Duration::from_secs(50),
};
let every = Duration::from_secs(10);
let mut old = run.clone();
old.snapshots = false;
for (run, written, lasts, cancels) in [
(&run, None, 500, 0),
(&run, Some(80), 150, 0),
(&run, Some(80), 250, 1),
(&old, Some(80), 250, 0),
] {
let fake = Fake::new(JobStatus::Running);
let flow = async {
if let Some(at) = written {
tokio::time::sleep(Duration::from_secs(at)).await;
fake.put_file(&request, SnapshotReason::Requested.name())
.await?;
tokio::time::sleep(Duration::from_secs(lasts - at)).await;
} else {
tokio::time::sleep(Duration::from_secs(lasts)).await;
}
Ok::<_, ExecError>(())
};
with_request_watch(&fake, run, limits, every, flow).await?;
assert_eq!(
fake.cancels.load(Ordering::SeqCst),
cancels,
"{} {written:?} {lasts}",
run.snapshots
);
}
Ok(())
}
#[tokio::test(start_paused = true)]
async fn a_follow_ends_with_its_job_while_a_request_read_hangs()
-> Result<(), Box<dyn std::error::Error>> {
use crate::runs::{StopLimits, with_request_watch};
let run = running()?;
let mut fake = Fake::new(JobStatus::Running);
fake.request_reads = RequestReads::Hung;
let limits = StopLimits {
proof: Duration::from_secs(100),
end: Duration::from_secs(50),
};
let flow = async {
tokio::time::sleep(Duration::from_secs(25)).await;
Ok::<_, ExecError>(())
};
let watched = with_request_watch(&fake, &run, limits, Duration::from_secs(10), flow);
tokio::time::timeout(Duration::from_secs(60), watched).await??;
assert_eq!(fake.cancels.load(Ordering::SeqCst), 0);
Ok(())
}
#[test]
fn the_note_is_removed_from_either_form_of_message() {
assert_eq!(
without_note(Some("artifacts not retrieved: x".to_string())),
None
);
assert_eq!(
without_note(Some("boom (artifacts not retrieved: x)".to_string())),
Some("boom".to_string())
);
assert_eq!(
without_note(Some("boom".to_string())),
Some("boom".to_string())
);
assert_eq!(without_note(None), None);
}
}