use crate::job::{Job, JobStatus};
use crate::scheduler::{sacct_user_filter, LogChunk, Scheduler, SlurmScheduler};
use crate::user_options::UserOptions;
use std::collections::HashSet;
use std::sync::{mpsc, Arc};
use std::thread;
use std::time::{Duration, Instant};
const WORKER_TIMEOUT: Duration = Duration::from_secs(30);
pub const TIMEOUT_ERROR: &str =
"squeue is not responding (no job update for 30 seconds). Retrying...";
const LOG_TAIL_LINES: usize = 100;
#[derive(Debug, Clone, PartialEq)]
pub struct LogFollowRequest {
pub path: String,
pub offset: Option<u64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LogFollowUpdate {
pub path: String,
pub chunk: Option<LogChunk>,
}
#[derive(Debug, Clone)]
pub struct Content {
pub job: Option<Job>,
pub job_list: Vec<Job>,
pub details_text: String,
pub log_text: String,
pub log_follow: Option<LogFollowUpdate>,
pub error: Option<String>,
}
pub enum ContentTick {
New(Box<Content>),
Pending,
TimedOut,
}
struct Worker {
receiver: mpsc::Receiver<Content>,
started: Instant,
}
pub struct ContentUpdater {
worker: Option<Worker>,
scheduler: Arc<dyn Scheduler>,
timeout: Duration,
}
impl Default for ContentUpdater {
fn default() -> Self {
Self::new()
}
}
impl ContentUpdater {
pub fn new() -> Self {
Self::with_scheduler(Arc::new(SlurmScheduler))
}
pub fn with_scheduler(scheduler: Arc<dyn Scheduler>) -> Self {
Self {
worker: None,
scheduler,
timeout: WORKER_TIMEOUT,
}
}
#[cfg(test)]
fn set_worker_timeout(&mut self, timeout: Duration) {
self.timeout = timeout;
}
pub fn tick(
&mut self,
job: Option<Job>,
command: String,
options: UserOptions,
log_request: Option<LogFollowRequest>,
) -> ContentTick {
match &self.worker {
Some(worker) => match worker.receiver.try_recv() {
Ok(mut content) => {
let job_clone = job.clone();
let log_request = advance_log_request(log_request, &content);
self.start_worker(job, command, options, log_request);
update_job_content(job_clone, &mut content);
ContentTick::New(Box::new(content))
}
Err(mpsc::TryRecvError::Empty) => {
if worker.started.elapsed() >= self.timeout {
self.worker = None;
ContentTick::TimedOut
} else {
ContentTick::Pending
}
}
Err(mpsc::TryRecvError::Disconnected) => {
self.worker = None;
ContentTick::Pending
}
},
None => {
self.start_worker(job, command, options, log_request);
ContentTick::Pending
}
}
}
fn start_worker(
&mut self,
job: Option<Job>,
command: String,
options: UserOptions,
log_request: Option<LogFollowRequest>,
) {
let (tx, rx) = mpsc::channel();
let scheduler = Arc::clone(&self.scheduler);
thread::spawn(move || {
tx.send(get_content(job, command, options, log_request, scheduler))
.unwrap_or(());
});
self.worker = Some(Worker {
receiver: rx,
started: Instant::now(),
});
}
}
fn advance_log_request(
request: Option<LogFollowRequest>,
content: &Content,
) -> Option<LogFollowRequest> {
let mut request = request?;
if let Some(update) = &content.log_follow {
if update.path == request.path {
if let Some(chunk) = &update.chunk {
request.offset = Some(chunk.offset);
}
}
}
Some(request)
}
fn get_content(
job: Option<Job>,
command: String,
options: UserOptions,
log_request: Option<LogFollowRequest>,
scheduler: Arc<dyn Scheduler>,
) -> Content {
let mut errors: Vec<String> = Vec::new();
let mut joblist = match scheduler.squeue_jobs(&command) {
Ok(jobs) => jobs,
Err(e) => {
errors.push(e.to_string());
Vec::new()
}
};
if options.show_completed_jobs {
match scheduler.sacct_jobs(&sacct_user_filter(&command)) {
Ok(jobs) => joblist.extend(jobs),
Err(e) => errors.push(e.to_string()),
}
}
let mut details_text = "No job selected".to_string();
let mut log_text = "No logfile available".to_string();
if let Some(ref job) = job {
details_text = scheduler
.job_details(&job.id)
.unwrap_or_else(|e| e.to_string());
if let Some(log_path) = job.get_stdout() {
log_text = scheduler
.log_tail(&log_path, LOG_TAIL_LINES)
.unwrap_or_else(|e| e.to_string());
}
if job.status != JobStatus::Pending {
if let Ok(Some(stats)) = scheduler.job_stats(&job.id) {
for entry in joblist.iter_mut().filter(|entry| entry.id == job.id) {
entry.stats = Some(Box::new(stats.clone()));
}
}
}
}
let log_follow = log_request.map(|request| {
let chunk = scheduler.log_since(&request.path, request.offset).ok();
LogFollowUpdate {
path: request.path,
chunk,
}
});
remove_completed_duplicates(&mut joblist);
Content {
job,
job_list: joblist,
details_text,
log_text,
log_follow,
error: if errors.is_empty() {
None
} else {
Some(errors.join("\n"))
},
}
}
fn remove_completed_duplicates(joblist: &mut Vec<Job>) {
let completing_ids: HashSet<String> = joblist
.iter()
.filter(|j| j.status == JobStatus::Completing)
.map(|j| j.id.clone())
.collect();
if completing_ids.is_empty() {
return;
}
joblist.retain(|j| !(j.status == JobStatus::Completed && completing_ids.contains(&j.id)));
}
fn update_job_content(job: Option<Job>, content: &mut Content) {
let new_job = match job {
Some(job) => job,
None => return,
};
let old_job = match &content.job {
Some(job) => job.clone(),
None => return,
};
if new_job.id != old_job.id {
set_content_loading(content);
} else {
if new_job.is_completed() {
set_content_no_info(content);
}
}
}
fn set_content_loading(content: &mut Content) {
content.details_text = "loading...".to_string();
content.log_text = "loading...".to_string();
}
fn set_content_no_info(content: &mut Content) {
let mut text = "Job id: ".to_string() + &content.job.as_ref().unwrap().id;
text = text + "\nJob name: " + &content.job.as_ref().unwrap().name;
text = text + "\nJob status: " + &content.job.as_ref().unwrap().status.to_string();
text = text + "\nTime used: " + &content.job.as_ref().unwrap().time;
text = text + "\nPartition: " + &content.job.as_ref().unwrap().partition;
text = text + "\nNodes: " + &content.job.as_ref().unwrap().nodes.to_string();
text = text + "\nWorkdir: " + &content.job.as_ref().unwrap().workdir;
text = text + "\nCommand: " + &content.job.as_ref().unwrap().command;
content.details_text = text;
content.log_text =
"Slurm has no database entry of the output file for completed jobs.".to_string();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scheduler::{FakeScheduler, SchedulerError};
fn tick_until_content(
updater: &mut ContentUpdater,
command: &str,
options: &UserOptions,
) -> Content {
tick_until_content_with_job(updater, None, command, options)
}
fn tick_until_content_with_job(
updater: &mut ContentUpdater,
job: Option<Job>,
command: &str,
options: &UserOptions,
) -> Content {
tick_until_content_with(updater, job, command, options, None)
}
fn tick_until_content_with(
updater: &mut ContentUpdater,
job: Option<Job>,
command: &str,
options: &UserOptions,
log_request: Option<LogFollowRequest>,
) -> Content {
for _ in 0..400 {
match updater.tick(
job.clone(),
command.to_string(),
options.clone(),
log_request.clone(),
) {
ContentTick::New(content) => return *content,
_ => thread::sleep(Duration::from_millis(5)),
}
}
panic!("the worker did not deliver content in time");
}
#[test]
fn content_pipeline_merges_squeue_and_sacct_jobs() {
let fake = Arc::new(FakeScheduler {
squeue_response: Ok(vec![
job("1", JobStatus::Running),
job("2", JobStatus::Completing),
]),
sacct_response: Ok(vec![
job("2", JobStatus::Completed),
job("3", JobStatus::Completed),
]),
..FakeScheduler::default()
});
let mut updater = ContentUpdater::with_scheduler(fake.clone());
let options = UserOptions {
show_completed_jobs: true,
..UserOptions::default()
};
let content = tick_until_content(&mut updater, "squeue -u alice --state=PD", &options);
assert!(content.error.is_none());
let ids: Vec<&str> = content.job_list.iter().map(|j| j.id.as_str()).collect();
assert_eq!(ids, vec!["1", "2", "3"]);
assert_eq!(content.job_list[1].status, JobStatus::Completing);
assert_eq!(content.job_list[2].status, JobStatus::Completed);
assert_eq!(
fake.squeue_commands.lock().unwrap().first().unwrap(),
"squeue -u alice --state=PD"
);
assert_eq!(
fake.sacct_filters.lock().unwrap().first().unwrap(),
&vec!["-u".to_string(), "alice".to_string()]
);
}
#[test]
fn stats_of_selected_job_are_attached_to_its_job_list_entry() {
use crate::job::JobStats;
let stats = JobStats {
mem_efficiency: Some(0.42),
elapsed_frac_of_limit: Some(0.61),
};
let fake = Arc::new(FakeScheduler {
squeue_response: Ok(vec![
job("1", JobStatus::Running),
job("2", JobStatus::Running),
]),
stats_response: Ok(Some(stats.clone())),
..FakeScheduler::default()
});
let mut updater = ContentUpdater::with_scheduler(fake);
let options = UserOptions::default();
let selected = job("1", JobStatus::Running);
let content = tick_until_content_with_job(&mut updater, Some(selected), "squeue", &options);
assert_eq!(content.job_list[0].stats, Some(Box::new(stats)));
assert_eq!(content.job_list[1].stats, None);
}
#[test]
fn stats_are_not_fetched_for_pending_jobs() {
use crate::job::JobStats;
let fake = Arc::new(FakeScheduler {
squeue_response: Ok(vec![job("1", JobStatus::Pending)]),
stats_response: Ok(Some(JobStats {
..JobStats::default()
})),
..FakeScheduler::default()
});
let mut updater = ContentUpdater::with_scheduler(fake);
let options = UserOptions::default();
let selected = job("1", JobStatus::Pending);
let content = tick_until_content_with_job(&mut updater, Some(selected), "squeue", &options);
assert_eq!(content.job_list[0].stats, None);
}
#[test]
fn failing_squeue_surfaces_error_in_content() {
let fake = Arc::new(FakeScheduler {
squeue_response: Err(SchedulerError::CommandFailed {
program: "squeue".to_string(),
stderr: "squeue: error: Invalid user: alice".to_string(),
}),
..FakeScheduler::default()
});
let mut updater = ContentUpdater::with_scheduler(fake);
let options = UserOptions {
show_completed_jobs: false,
..UserOptions::default()
};
let content = tick_until_content(&mut updater, "squeue -u alice", &options);
let error = content.error.expect("the squeue error must surface");
assert!(error.contains("squeue"));
assert!(error.contains("Invalid user"));
}
#[test]
fn log_follow_chunks_flow_through_the_content_pipeline() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("job.log");
std::fs::write(&path, "one\ntwo\n").unwrap();
let path = path.to_str().unwrap().to_string();
let options = UserOptions::default();
let mut updater = ContentUpdater::with_scheduler(Arc::new(FakeScheduler::default()));
let request = LogFollowRequest {
path: path.clone(),
offset: None,
};
let content =
tick_until_content_with(&mut updater, None, "squeue", &options, Some(request));
let update = content.log_follow.expect("a log update must arrive");
assert_eq!(update.path, path);
let chunk = update.chunk.expect("the file exists");
assert_eq!(chunk.content, "one\ntwo\n");
assert!(!chunk.truncated);
std::fs::OpenOptions::new()
.append(true)
.open(&path)
.and_then(|mut f| std::io::Write::write_all(&mut f, b"three\n"))
.unwrap();
let mut updater = ContentUpdater::with_scheduler(Arc::new(FakeScheduler::default()));
let request = LogFollowRequest {
path: path.clone(),
offset: Some(chunk.offset),
};
let content =
tick_until_content_with(&mut updater, None, "squeue", &options, Some(request));
let chunk = content.log_follow.unwrap().chunk.unwrap();
assert_eq!(chunk.content, "three\n");
}
#[test]
fn log_follow_missing_file_yields_waiting_update_not_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("not_written_yet.log");
let path = path.to_str().unwrap().to_string();
let options = UserOptions::default();
let mut updater = ContentUpdater::with_scheduler(Arc::new(FakeScheduler::default()));
let request = LogFollowRequest {
path: path.clone(),
offset: None,
};
let content =
tick_until_content_with(&mut updater, None, "squeue", &options, Some(request));
let update = content.log_follow.unwrap();
assert_eq!(update.path, path);
assert_eq!(update.chunk, None);
assert!(content.error.is_none());
}
#[test]
fn without_open_log_view_no_log_update_is_computed() {
let mut updater = ContentUpdater::with_scheduler(Arc::new(FakeScheduler::default()));
let content = tick_until_content(&mut updater, "squeue", &UserOptions::default());
assert_eq!(content.log_follow, None);
}
#[test]
fn advance_log_request_moves_past_the_delivered_chunk() {
let request = LogFollowRequest {
path: "/a.log".to_string(),
offset: None,
};
let mut content = Content {
job: None,
job_list: Vec::new(),
details_text: String::new(),
log_text: String::new(),
log_follow: Some(LogFollowUpdate {
path: "/a.log".to_string(),
chunk: Some(LogChunk {
content: "x\n".to_string(),
offset: 42,
truncated: false,
}),
}),
error: None,
};
let advanced = advance_log_request(Some(request.clone()), &content).unwrap();
assert_eq!(advanced.offset, Some(42));
content.log_follow.as_mut().unwrap().path = "/other.log".to_string();
let advanced = advance_log_request(Some(request.clone()), &content).unwrap();
assert_eq!(advanced.offset, None);
content.log_follow = Some(LogFollowUpdate {
path: "/a.log".to_string(),
chunk: None,
});
let advanced = advance_log_request(Some(request), &content).unwrap();
assert_eq!(advanced.offset, None);
assert_eq!(advance_log_request(None, &content), None);
}
struct BlockingScheduler;
impl Scheduler for BlockingScheduler {
fn squeue_jobs(&self, _command: &str) -> Result<Vec<Job>, SchedulerError> {
thread::sleep(Duration::from_secs(60));
Ok(Vec::new())
}
fn sacct_jobs(&self, _user_filter: &[String]) -> Result<Vec<Job>, SchedulerError> {
Ok(Vec::new())
}
fn job_details(&self, _job_id: &str) -> Result<String, SchedulerError> {
Ok(String::new())
}
fn job_stats(&self, _job_id: &str) -> Result<Option<crate::job::JobStats>, SchedulerError> {
Ok(None)
}
fn cancel_job(&self, _job_id: &str) -> Result<(), SchedulerError> {
Ok(())
}
fn job_nodes(&self, _job_id: &str) -> Result<Vec<String>, SchedulerError> {
Ok(Vec::new())
}
fn log_tail(&self, _path: &str, _lines: usize) -> Result<String, SchedulerError> {
Ok(String::new())
}
}
#[test]
fn hung_worker_times_out_and_is_replaced() {
let mut updater = ContentUpdater::with_scheduler(Arc::new(BlockingScheduler));
updater.set_worker_timeout(Duration::ZERO);
let options = UserOptions::default();
assert!(matches!(
updater.tick(None, "squeue".to_string(), options.clone(), None),
ContentTick::Pending
));
assert!(matches!(
updater.tick(None, "squeue".to_string(), options.clone(), None),
ContentTick::TimedOut
));
assert!(matches!(
updater.tick(None, "squeue".to_string(), options, None),
ContentTick::Pending
));
}
fn job(id: &str, status: JobStatus) -> Job {
Job::new(
id,
&format!("job_{}", id),
status,
"0-00:01:00",
"main",
1,
"/work",
"cmd",
None,
)
}
#[test]
fn remove_completed_duplicates_keeps_completing_job() {
let mut joblist = vec![
job("1", JobStatus::Running),
job("2", JobStatus::Completing),
job("3", JobStatus::Completed),
job("2", JobStatus::Completed),
job("4", JobStatus::Pending),
job("2", JobStatus::Completed),
];
remove_completed_duplicates(&mut joblist);
let ids: Vec<&str> = joblist.iter().map(|j| j.id.as_str()).collect();
assert_eq!(ids, vec!["1", "2", "3", "4"]);
assert_eq!(joblist[1].status, JobStatus::Completing);
assert_eq!(joblist[2].status, JobStatus::Completed);
}
#[test]
fn remove_completed_duplicates_no_completing_jobs_is_noop() {
let mut joblist = vec![
job("1", JobStatus::Completed),
job("1", JobStatus::Completed),
job("2", JobStatus::Running),
];
remove_completed_duplicates(&mut joblist);
assert_eq!(joblist.len(), 3);
}
}