use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::time::{Duration, SystemTime};
use secrecy::SecretString;
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,
local_manifest,
};
use crate::runpod::{POD_FILE, SSH_DIR};
use crate::train::{TrainError, Trainer};
pub const HF_CACHE_DIR: &str = ".hf-cache";
const MAX_FAILURES: u32 = 5;
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(
"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, workdir: &str, target: &str) -> Result<RunRecord, RunsError> {
let now = SystemTime::now();
let id = new_run_id(now);
let record = RunRecord {
remote_dir: format!("{workdir}/{id}"),
id,
target: target.to_string(),
created: rfc3339(now),
job: None,
state: RunState::Preparing,
message: None,
};
runs.save(&record)?;
Ok(record)
}
pub async fn start<E: Executor, T: Trainer>(
ctx: &RunCtx<'_, E>,
trainer: &T,
launch: Launch<'_>,
mut record: RunRecord,
) -> Result<RunRecord, RunError> {
match launch_job(ctx, trainer, launch, &record).await {
Ok(job) => record_started(ctx, record, job).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,
) -> Result<RunRecord, RunError> {
record.job = Some(job.clone());
record.state = RunState::Running;
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,
) -> Result<JobId, RunError> {
let local = ctx.runs.run_dir(&record.id)?;
let root = launch.runtime.root(&record.remote_dir);
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 job = launch.runtime.job(JobSpec {
run_id: &record.id,
run_dir: &record.remote_dir,
cache_dir: &cache_dir,
commands: &trainer.commands(),
env: &trainer.env(&root),
secrets: launch.secrets,
});
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();
return Ok(Outcome {
record,
summary,
retrieved,
});
}
let metrics = format!("{}/{}", record.remote_dir, trainer.metrics_file());
let mut stream = ctx.executor.tail(&metrics, 0);
let mut summary = MetricsSummary::default();
let status = follow(ctx, &job, &mut stream, &mut summary).await?;
let (state, message) = outcome(status, &summary, &record.id);
let succeeded = state == RunState::Succeeded;
let (state, message, retrieved) =
match retrieve(ctx.executor, trainer, &record.remote_dir, &local, succeeded).await {
Ok(Retrieved::Ok) => (state, message, true),
Ok(Retrieved::NoOutput) => {
(RunState::Failed, Some(no_output_message(&record.id)), true)
},
Err(error) if succeeded => return Err(error.into()),
Err(error) => (state, Some(not_retrieved(message, &error)), false),
};
record.message = message;
record.state = state;
ctx.runs.save(&record)?;
Ok(Outcome {
record,
summary,
retrieved,
})
}
#[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,
succeeded: bool,
) -> Result<Retrieved, ExecError> {
let artifacts = trainer.artifacts();
let mut entries = artifacts.entries;
entries.push(JOB_LOG.to_string());
let manifest = executor
.manifest(remote, &entries, &artifacts.exclude)
.await?;
let no_output = succeeded
&& artifacts
.required
.as_deref()
.is_some_and(|required| !has_required(&manifest, required));
executor
.download(remote, local, &entries, &artifacts.exclude)
.await?;
let local = local.to_path_buf();
let exclude = artifacts.exclude.clone();
tokio::task::spawn_blocking(move || verify(&local, &entries, &exclude, &manifest))
.await
.map_err(|error| ExecError::Protocol(format!("the verification task failed: {error}")))??;
Ok(if no_output {
Retrieved::NoOutput
} else {
Retrieved::Ok
})
}
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(id: &str) -> String {
format!("the job succeeded but left no output (see runs/{id}/{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 succeeded = record.state == RunState::Succeeded;
if let Err(error) = retrieve(ctx.executor, trainer, &record.remote_dir, &local, succeeded).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;
},
}
}
}
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(metric) = summary.add(&line) {
bus.publish(Event::Metric(metric));
}
}
}
fn unreachable_target(error: &ExecError, failures: u32) {
tracing::warn!("cannot reach the job ({failures}/{MAX_FAILURES}): {error}");
}
fn outcome(status: JobStatus, summary: &MetricsSummary, id: &str) -> (RunState, Option<String>) {
let log = format!("see runs/{id}/{JOB_LOG}");
match status {
JobStatus::Exited(0) if summary.lines > 0 => (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, false).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::*;
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, "r1");
assert_eq!(state, RunState::Failed);
assert!(message.is_some_and(|message| message.contains("plugin was not loaded")));
summary.add(r#"{"event": "begin", "time": 1}"#);
assert_eq!(
outcome(JobStatus::Exited(0), &summary, "r1"),
(RunState::Succeeded, None)
);
assert_eq!(
outcome(JobStatus::Exited(2), &summary, "r1"),
(
RunState::Failed,
Some("the job exited with code 2 (see runs/r1/job.log)".to_string())
)
);
assert_eq!(
outcome(JobStatus::Cancelled, &summary, "r1"),
(RunState::Cancelled, None)
);
assert_eq!(outcome(JobStatus::Lost, &summary, "r1").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";
struct Fake {
status: JobStatus,
metrics: String,
spawn_fails: bool,
download_fails: bool,
failing_read: Option<u32>,
break_on_download: Option<PathBuf>,
manifest: Vec<FileDigest>,
reads: AtomicU32,
cancels: AtomicU32,
}
impl Fake {
fn new(status: JobStatus) -> Self {
Self {
status,
metrics: METRICS.to_string(),
spawn_fails: false,
download_fails: false,
failing_read: None,
break_on_download: None,
manifest: Vec::new(),
reads: AtomicU32::new(0),
cancels: AtomicU32::new(0),
}
}
}
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 upload(
&self,
_local: &Path,
_remote: &str,
_skip: &[String],
) -> impl Future<Output = Result<(), ExecError>> + Send {
ready(Ok(()))
}
fn spawn(
&self,
_job: &JobCommand,
) -> impl Future<Output = Result<JobId, ExecError>> + Send {
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 {
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));
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 {
ready(Ok(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 {
ready(Ok(self.manifest.clone()))
}
}
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::new(),
required: Some("output".to_string()),
}
}
}
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,
})
}
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 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_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_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, 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_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, 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(())
}
#[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);
}
}