use std::future::Future;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::{RunError, RunRecord, RunState};
use crate::exec::{ExecError, Executor, JobId, JobStatus};
use crate::train::{OUTPUT_DIR, SNAPSHOT_FILE, SNAPSHOT_REQUEST};
pub const STOP_TIMEOUT: Duration = Duration::from_mins(30);
const PROOF_LIMIT: u64 = 64 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SnapshotReason {
Requested,
Deadline,
Cost,
Disk,
}
impl SnapshotReason {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Requested => "requested",
Self::Deadline => "deadline",
Self::Cost => "cost",
Self::Disk => "disk",
}
}
#[must_use]
pub fn parse(text: &str) -> Self {
match text.trim() {
"deadline" => Self::Deadline,
"cost" => Self::Cost,
"disk" => Self::Disk,
_ => Self::Requested,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Snapshot {
pub checkpoint: String,
pub step: u64,
pub reason: SnapshotReason,
}
#[derive(Debug, Deserialize)]
struct Written {
checkpoint: String,
step: u64,
#[serde(default)]
reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Proof {
None,
Saved(Snapshot),
Invalid(String),
}
#[must_use]
pub fn parse_proof(content: &[u8]) -> Proof {
if content.iter().all(u8::is_ascii_whitespace) {
return Proof::None;
}
let written: Written = match serde_json::from_slice(content) {
Ok(written) => written,
Err(error) => {
return Proof::Invalid(format!(
"{SNAPSHOT_FILE} is not valid (line {}, column {})",
error.line(),
error.column()
));
},
};
if written.checkpoint != expected_checkpoint(written.step) {
return Proof::Invalid(format!("{SNAPSHOT_FILE} names an invalid checkpoint path"));
}
Proof::Saved(Snapshot {
checkpoint: written.checkpoint,
step: written.step,
reason: SnapshotReason::parse(written.reason.as_deref().unwrap_or_default()),
})
}
fn expected_checkpoint(step: u64) -> String {
format!("{OUTPUT_DIR}/checkpoint-{step}")
}
pub async fn read_proof<E: Executor>(executor: &E, remote_dir: &str) -> Result<Proof, ExecError> {
let path = format!("{remote_dir}/{SNAPSHOT_FILE}");
let content = executor.read_from(&path, 0, PROOF_LIMIT).await?;
Ok(parse_proof(&content))
}
pub async fn request_snapshot<E: Executor>(
executor: &E,
record: &RunRecord,
reason: SnapshotReason,
) -> Result<(), RunError> {
if record.job.is_none() {
return Err(RunError::NotStarted(record.id.clone()));
}
if record.state != RunState::Running {
return Err(RunError::NotRunning(
record.id.clone(),
record.state.name().to_string(),
));
}
if !record.snapshots {
return Err(RunError::NoSnapshots(record.id.clone()));
}
let path = format!("{}/{SNAPSHOT_REQUEST}", record.remote_dir);
executor.put_file(&path, reason.name()).await?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StopLimits {
pub proof: Duration,
pub end: Duration,
}
pub const STOP_LIMITS: StopLimits = StopLimits {
proof: STOP_TIMEOUT,
end: Duration::from_mins(10),
};
pub async fn with_stop_fallback<E: Executor, F: Future>(
executor: &E,
job: &JobId,
limits: StopLimits,
flow: F,
) -> F::Output {
let mut flow = std::pin::pin!(flow);
if let Err(output) = beside(&mut flow, tokio::time::sleep(limits.proof)).await {
return output;
}
match beside(&mut flow, late_check(executor, job, limits.proof)).await {
Err(output) => return output,
Ok(false) => {},
Ok(true) => {
if let Err(output) = beside(&mut flow, tokio::time::sleep(limits.end)).await {
return output;
}
if let Err(output) = beside(&mut flow, late_stop(executor, job)).await {
return output;
}
},
}
flow.await
}
pub const REQUEST_POLL: Duration = Duration::from_secs(30);
pub async fn with_request_watch<E: Executor, F: Future>(
executor: &E,
run: &RunRecord,
limits: StopLimits,
every: Duration,
flow: F,
) -> F::Output {
let Some(job) = run.job.as_ref().filter(|_| run.snapshots) else {
return flow.await;
};
let mut flow = std::pin::pin!(flow);
let request = async {
let mut ticks = tokio::time::interval(every);
ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticks.tick().await;
if requested(executor, &job.dir).await {
break;
}
}
};
if let Err(output) = beside(&mut flow, request).await {
return output;
}
with_stop_fallback(executor, job, limits, flow).await
}
async fn beside<F: Future + Unpin, W: Future>(
flow: &mut F,
work: W,
) -> Result<W::Output, F::Output> {
tokio::select! {
biased;
output = flow => Err(output),
done = work => Ok(done),
}
}
async fn requested<E: Executor>(executor: &E, run_dir: &str) -> bool {
let path = format!("{run_dir}/{SNAPSHOT_REQUEST}");
match executor.read_from(&path, 0, 1).await {
Ok(content) => !content.is_empty(),
Err(error) => {
tracing::debug!("cannot look for a snapshot request: {error}");
false
},
}
}
async fn late_check<E: Executor>(executor: &E, job: &JobId, waited: Duration) -> bool {
let checked = async {
let proven = read_proof(executor, &job.dir).await? != Proof::None;
let running = executor.status(job).await? == JobStatus::Running;
if running && !proven {
executor.cancel(job).await?;
}
Ok::<_, ExecError>((proven, running))
};
let note = match checked.await {
Ok((true, running)) => return running,
Ok((false, false)) => return false,
Ok((false, true)) => format!(
"no snapshot {} min after it was asked for: the job was cancelled",
waited.as_secs() / 60
),
Err(error) => format!("cannot cancel a job that gave no snapshot: {error}"),
};
tracing::warn!("{note}");
false
}
async fn late_stop<E: Executor>(executor: &E, job: &JobId) {
let stopped = async {
if executor.status(job).await? != JobStatus::Running {
return Ok(false);
}
executor.cancel(job).await?;
Ok::<_, ExecError>(true)
};
let note = match stopped.await {
Ok(true) => "the job saved its snapshot but did not end: it was cancelled".to_string(),
Ok(false) => return,
Err(error) => format!("cannot cancel a job that did not end: {error}"),
};
tracing::warn!("{note}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_proof_names_its_checkpoint_step_and_reason() {
let proof = parse_proof(
br#"{"checkpoint": "output/checkpoint-120", "step": 120, "time": 1.5, "reason": "cost"}"#,
);
assert_eq!(
proof,
Proof::Saved(Snapshot {
checkpoint: "output/checkpoint-120".into(),
step: 120,
reason: SnapshotReason::Cost,
})
);
let without = parse_proof(br#"{"checkpoint": "output/checkpoint-1", "step": 1}"#);
assert!(
matches!(
without,
Proof::Saved(Snapshot {
reason: SnapshotReason::Requested,
..
})
),
"{without:?}"
);
assert_eq!(parse_proof(b""), Proof::None);
assert_eq!(parse_proof(b" \n"), Proof::None);
}
#[test]
fn a_proof_never_names_a_path_outside_the_run() {
for checkpoint in [
"/etc/passwd",
"../other",
"output/../..",
"output//checkpoint-1",
"output/./checkpoint-1",
"output/check point",
"output/other-1",
"output/checkpoint-2",
"output/checkpoint-1/x",
"checkpoint-1",
"",
] {
let text = format!(r#"{{"checkpoint": {checkpoint:?}, "step": 1}}"#);
assert!(
matches!(parse_proof(text.as_bytes()), Proof::Invalid(_)),
"{checkpoint:?}"
);
}
assert!(matches!(parse_proof(b"{not json"), Proof::Invalid(_)));
}
#[test]
fn reasons_read_back_and_default_to_requested() {
for reason in [
SnapshotReason::Requested,
SnapshotReason::Deadline,
SnapshotReason::Cost,
SnapshotReason::Disk,
] {
assert_eq!(SnapshotReason::parse(reason.name()), reason);
}
assert_eq!(
SnapshotReason::parse(" deadline\n"),
SnapshotReason::Deadline
);
assert_eq!(SnapshotReason::parse("anything"), SnapshotReason::Requested);
}
}