use std::{path::PathBuf, time::Duration};
use anyhow::Error;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use structdiff::{Difference, StructDiff};
use crate::{parse_slurm_duration, JobState};
use std::{
collections::{HashMap, HashSet},
fs::{create_dir_all, File},
future::Future,
io::BufWriter,
path::Path,
process::Command,
time::{Instant, SystemTime},
};
#[cfg(feature = "ssh")]
use async_ssh2_tokio::Client;
use chrono::{DateTime, Utc};
use rayon::iter::IntoParallelRefIterator;
pub(crate) const SQUEUE_FORMAT_STR: &str =
"%a|%A|%B|%c|%C|%D|%e|%E|%f|%F|%G|%i|%l|%L|%j|%m|%M|%p|%P|%T|%r|%S|%V|%Z|%o";
#[derive(Debug, Clone, Serialize, Deserialize, Difference)]
pub struct SqueueRow {
pub account: String,
pub job_id: String,
pub exec_host: Option<String>,
pub min_cpus: usize,
pub cpus: usize,
pub nodes: usize,
pub end_time: Option<NaiveDateTime>,
pub dependency: Option<String>,
pub features: String,
pub array_job_id: String,
pub group: String,
pub step_job_id: (String, Option<String>),
pub time_limit: Option<Duration>,
#[difference(skip)]
pub time_left: Option<Duration>,
pub name: String,
pub min_memory: String,
#[difference(skip)]
pub time: Option<Duration>,
pub priority: f64,
pub partition: String,
pub state: JobState,
pub reason: String,
pub start_time: Option<NaiveDateTime>,
pub submit_time: NaiveDateTime,
pub work_dir: PathBuf,
pub command: String,
}
impl SqueueRow {
fn parse_from_strs(vals: &[&str]) -> Result<Self, Error> {
if vals.len() != 25 {
return Err(Error::msg("Invalid length of values."));
}
let mut step_job_id = vals[11].split("_");
Ok(Self {
account: vals[0].to_string(),
job_id: vals[1].to_string(),
exec_host: match vals[2] {
"n/a" => None,
s => Some(s.to_string()),
},
min_cpus: vals[3].parse()?,
cpus: vals[4].parse()?,
nodes: vals[5].parse()?,
end_time: match vals[6] {
"N/A" => None,
s => Some(NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")?),
},
dependency: match vals[7] {
"(null)" => None,
s => Some(s.to_string()),
},
features: vals[8].to_string(),
array_job_id: vals[9].to_string(),
group: vals[10].to_string(),
step_job_id: (
step_job_id.next().unwrap().to_string(),
step_job_id.next().map(|s| s.to_string()),
), time_limit: match vals[12] {
"INVALID" => None,
s => parse_slurm_duration(s).map(Some).unwrap_or_default(),
}, time_left: match vals[13] {
"INVALID" => None,
s => parse_slurm_duration(s).map(Some).unwrap_or_default(),
}, name: vals[14].to_string(), min_memory: vals[15].to_string(), time: match vals[16] {
"INVALID" => None,
s => parse_slurm_duration(s).map(Some).unwrap_or_default(),
},
priority: vals[17]
.parse()
.inspect_err(|err| eprintln!("Priority failed to parse! {err:?}"))?, partition: vals[18].to_string(),
state: vals[19].parse()?,
reason: vals[20].to_string(),
start_time: match vals[21] {
"N/A" => None,
s => Some(NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")?),
},
submit_time: NaiveDateTime::parse_from_str(vals[22], "%Y-%m-%dT%H:%M:%S")?,
work_dir: vals[23].parse()?,
command: vals[24].to_string(),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum SqueueMode {
#[default]
ALL,
MINE,
JOBIDS(Vec<String>),
}
pub async fn get_squeue_res<F, Fut>(
mode: &SqueueMode,
execute_cmd: F,
) -> Result<(DateTime<Utc>, Vec<SqueueRow>), Error>
where
F: FnOnce(String) -> Fut,
Fut: Future<Output = Result<String, Error>>,
{
let extra_arg = match mode {
SqueueMode::ALL => String::default(),
SqueueMode::MINE => String::from("--me"),
SqueueMode::JOBIDS(vec) => format!("-j {}", vec.join(",")),
};
let result = execute_cmd(format!(
"squeue -h -a -M all -t all --format='{SQUEUE_FORMAT_STR}' {extra_arg}"
))
.await?;
let res_lines = result.split("\n");
let time: DateTime<Utc> = SystemTime::now().into();
let d: Vec<SqueueRow> = res_lines
.filter_map(|line| {
if line.is_empty() {
return None;
}
let res = SqueueRow::parse_from_strs(&line.split("|").collect::<Vec<_>>());
match res {
Ok(row) => Some(row),
Err(err) => {
println!("[!] {:?} for {:?}", err, &line);
None
}
}
})
.collect();
Ok((time, d))
}
pub async fn get_squeue_res_locally(
mode: &SqueueMode,
) -> Result<(DateTime<Utc>, Vec<SqueueRow>), Error> {
get_squeue_res(mode, |cmd_s| async move {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(&cmd_s);
let d = Instant::now();
let out = cmd.output()?;
let s = String::from_utf8(out.stdout)?;
println!("Running squeue took {:?}", d.elapsed());
Ok(s)
})
.await
}
#[cfg(feature = "ssh")]
pub async fn get_squeue_res_ssh(
client: &Client,
mode: &SqueueMode,
) -> Result<(DateTime<Utc>, Vec<SqueueRow>), Error> {
get_squeue_res(mode, |cmd| async move {
let r = client.execute(&cmd).await?;
Ok(r.stdout)
})
.await
}
use rayon::prelude::*;
pub async fn squeue_diff<'b, F, Fut>(
get_squeue: F,
path: &Path,
known_jobs: &'b mut HashMap<String, SqueueRow>,
all_ids: &'b mut HashSet<String>,
) -> Result<(DateTime<Utc>, Vec<SqueueRow>), Error>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<(DateTime<Utc>, Vec<SqueueRow>), Error>>,
{
let (time, rows) = get_squeue().await?;
let cleaned_time = time.to_rfc3339().replace(":", "_");
let row_ids = rows
.iter()
.map(|r| r.job_id.clone())
.collect::<HashSet<_>>();
if rows.len() != row_ids.len() {
eprintln!("Count mismatch: {} != {}", rows.len(), row_ids.len());
}
create_dir_all(path)?;
let id_save_path = path.join(format!("{cleaned_time}.json"));
if let Err(e) = serde_json::to_writer(
BufWriter::new(File::create(id_save_path).unwrap()),
&row_ids,
) {
eprintln!("Failed to create file for all jobs ids: {e:?}");
}
*known_jobs = rows
.par_iter()
.map(|row| {
if let Some(prev_row) = known_jobs.get(&row.job_id) {
let diff = prev_row.diff(row);
if !diff.is_empty() {
let save_path = path
.join(&row.job_id)
.join(format!("DELTA-{cleaned_time}.json"));
if let Err(e) = serde_json::to_writer(
BufWriter::new(File::create(save_path).unwrap()),
&diff,
) {
eprintln!("Failed to create file for {}: {:?}", row.job_id, e);
}
}
(row.job_id.clone(), row.clone())
} else {
if all_ids.contains(&row.job_id) {
eprintln!("Job re-appeared! Maybe IDs get reused?");
}
let folder_path = path.join(&row.job_id);
create_dir_all(&folder_path).unwrap();
let save_path = folder_path.join(format!("{cleaned_time}.json"));
if let Err(e) =
serde_json::to_writer(BufWriter::new(File::create(save_path).unwrap()), &row)
{
eprintln!("Failed to create file for {}: {:?}", row.job_id, e);
}
(row.job_id.clone(), row.clone())
}
})
.collect();
all_ids.extend(row_ids);
Ok((time, rows))
}
#[cfg(test)]
mod tests {
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
};
use crate::data_extraction::{get_squeue_res_locally, SqueueMode};
#[cfg(feature = "ssh")]
use crate::login_with_cfg;
#[cfg(feature = "ssh")]
#[tokio::test]
async fn test_squeue_loop() {
let login_cfg = crate::misc::get_config_from_env();
let client = login_with_cfg(&login_cfg).await.unwrap();
let mut known_jobs = HashMap::default();
let mut all_ids = HashSet::default();
let path = PathBuf::new().join("test_squeue_loop-14-01-2025");
let mut i = 0;
loop {
use crate::data_extraction::{get_squeue_res_ssh, squeue_diff};
squeue_diff(
|| get_squeue_res_ssh(&client, &SqueueMode::ALL),
&path,
&mut known_jobs,
&mut all_ids,
)
.await
.unwrap();
i += 1;
println!("Ran for {i} iterations, sleeping...");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
#[tokio::test]
async fn test_local() {
let res = get_squeue_res_locally(&SqueueMode::ALL).await.unwrap();
println!("Got {} results", res.1.len())
}
}