use crate::spec::JobSpec;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobState {
Queued,
Starting,
Running,
Completed,
Failed,
Killed,
Timeout,
Oom,
Cancelled,
Skipped,
}
impl JobState {
pub fn is_terminal(self) -> bool {
!matches!(self, Self::Queued | Self::Starting | Self::Running)
}
pub fn is_active(self) -> bool {
matches!(self, Self::Starting | Self::Running)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Starting => "starting",
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Killed => "killed",
Self::Timeout => "timeout",
Self::Oom => "oom",
Self::Cancelled => "cancelled",
Self::Skipped => "skipped",
}
}
}
impl std::fmt::Display for JobState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for JobState {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s.trim().to_ascii_lowercase().as_str() {
"queued" => Ok(Self::Queued),
"starting" => Ok(Self::Starting),
"running" => Ok(Self::Running),
"completed" => Ok(Self::Completed),
"failed" => Ok(Self::Failed),
"killed" => Ok(Self::Killed),
"timeout" => Ok(Self::Timeout),
"oom" => Ok(Self::Oom),
"cancelled" | "canceled" => Ok(Self::Cancelled),
"skipped" => Ok(Self::Skipped),
other => Err(format!(
"unknown job state `{other}`. Use one of these states: queued, starting, \
running, completed, failed, killed, timeout, oom, cancelled"
)),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Usage {
pub max_rss: u64,
pub cpu_secs: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobStatus {
pub id: uuid::Uuid,
pub name: String,
#[serde(default)]
pub command: Vec<String>,
#[serde(default)]
pub cwd: String,
pub state: JobState,
pub pid: Option<i32>,
#[serde(default)]
pub last_pid: Option<i32>,
#[serde(default)]
pub supervisor_pid: Option<i32>,
pub exit_code: Option<i32>,
pub signal: Option<i32>,
pub submitted_at: u64,
#[serde(default)]
pub sequence: u64,
pub started_at: Option<u64>,
pub finished_at: Option<u64>,
pub cpu: u64,
pub mem: u64,
#[serde(default)]
pub claim_source: String,
#[serde(default)]
pub group: Option<uuid::Uuid>,
#[serde(default)]
pub group_name: Option<String>,
pub usage: Usage,
#[serde(default)]
pub forced: bool,
#[serde(default)]
pub forced_reason: Option<String>,
#[serde(default)]
pub blocked_reason: Option<String>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub needs: Vec<uuid::Uuid>,
#[serde(default)]
pub after: Vec<uuid::Uuid>,
#[serde(default)]
pub locks: Vec<String>,
#[serde(default)]
pub attempts: u32,
#[serde(default)]
pub retries_left: u32,
#[serde(default)]
pub caused_by: Option<uuid::Uuid>,
pub tags: Vec<String>,
}
impl JobStatus {
pub fn new(spec: &JobSpec) -> Self {
Self {
id: spec.id,
name: spec.name.clone(),
command: spec.command.clone(),
cwd: spec.cwd.to_string_lossy().into_owned(),
state: JobState::Queued,
pid: None,
last_pid: None,
supervisor_pid: None,
exit_code: None,
signal: None,
submitted_at: spec.submitted_at,
sequence: 0,
started_at: None,
finished_at: None,
cpu: spec.cpu,
mem: spec.mem,
claim_source: spec.claim_source.clone(),
group: spec.group,
group_name: spec.group_name.clone(),
usage: Usage::default(),
forced: false,
forced_reason: None,
blocked_reason: None,
error: None,
needs: spec.needs.clone(),
after: spec.after.clone(),
locks: spec.locks.clone(),
attempts: 0,
retries_left: spec.retries,
caused_by: None,
tags: spec.tags.clone(),
}
}
pub fn elapsed(&self) -> Option<std::time::Duration> {
let start = self.started_at?;
let end = self.finished_at.unwrap_or_else(crate::sys::now_secs);
Some(std::time::Duration::from_secs(end.saturating_sub(start)))
}
}
pub fn write_atomic(path: &Path, bytes: &[u8], mode: u32) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let dir = path.parent().unwrap_or_else(|| Path::new("."));
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let unique = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = dir.join(format!(
".{}.tmp.{}.{}",
path.file_name().and_then(|f| f.to_str()).unwrap_or("f"),
std::process::id(),
unique
));
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(&tmp)
.with_context(|| format!("creating {}", tmp.display()))?;
f.write_all(bytes)
.with_context(|| format!("writing {}", tmp.display()))?;
f.sync_all()
.with_context(|| format!("writing {} to the disk", tmp.display()))?;
}
if let Err(e) = std::fs::rename(&tmp, path) {
std::fs::remove_file(&tmp).ok();
return Err(e).with_context(|| format!("renaming {} into place", tmp.display()));
}
if let Ok(handle) = std::fs::File::open(dir) {
handle.sync_all().ok();
}
Ok(())
}
pub fn read_status(dir: &Path) -> Result<JobStatus> {
let path = dir.join("status.json");
let text =
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))
}
pub fn write_status(dir: &Path, status: &JobStatus) -> Result<()> {
let bytes = serde_json::to_vec_pretty(status)?;
write_atomic(&dir.join("status.json"), &bytes, 0o600)
}
pub fn read_all_from_disk() -> Vec<JobStatus> {
let Ok(dir) = crate::paths::jobs_dir() else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut jobs: Vec<JobStatus> = entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| read_status(&e.path()).ok())
.collect();
jobs.sort_by_key(|j| (j.submitted_at, j.sequence));
jobs
}
pub fn read_spec(dir: &Path) -> Result<JobSpec> {
let path = dir.join("spec.json");
let text =
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))
}
pub fn write_spec(dir: &Path, spec: &JobSpec) -> Result<()> {
let bytes = serde_json::to_vec_pretty(spec)?;
write_atomic(&dir.join("spec.json"), &bytes, 0o600)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn terminal_states_are_classified_correctly() {
for s in [
JobState::Completed,
JobState::Failed,
JobState::Killed,
JobState::Timeout,
JobState::Oom,
JobState::Cancelled,
] {
assert!(s.is_terminal(), "{s} should be terminal");
assert!(!s.is_active(), "{s} should not be active");
}
for s in [JobState::Queued, JobState::Starting, JobState::Running] {
assert!(!s.is_terminal(), "{s} should not be terminal");
}
assert!(JobState::Running.is_active());
assert!(!JobState::Queued.is_active());
}
#[test]
fn states_round_trip_through_strings() {
use std::str::FromStr;
for s in [
JobState::Queued,
JobState::Running,
JobState::Completed,
JobState::Oom,
JobState::Cancelled,
] {
assert_eq!(JobState::from_str(s.as_str()).unwrap(), s);
}
assert_eq!(JobState::from_str("canceled").unwrap(), JobState::Cancelled);
assert!(JobState::from_str("wat").is_err());
}
#[test]
fn atomic_write_applies_the_requested_mode() {
let dir = std::env::temp_dir().join(format!("qex-atomic-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("secret.json");
write_atomic(&path, b"{}", 0o600).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"other users must not read a captured environment"
);
write_atomic(&path, b"{\"a\":1}", 0o600).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"a\":1}");
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.collect();
assert!(leftovers.is_empty(), "atomic write left temp files behind");
std::fs::remove_dir_all(&dir).ok();
}
}