use crate::paths;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PauseRecord {
pub paused_at: u64,
pub by_pid: i32,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub until: Option<u64>,
#[serde(default)]
pub fault: bool,
}
impl PauseRecord {
pub fn new(by_pid: i32, reason: Option<String>, until: Option<u64>) -> Self {
Self {
paused_at: crate::sys::now_secs(),
by_pid,
reason,
until,
fault: false,
}
}
pub fn expired(&self, now: u64) -> bool {
matches!(self.until, Some(end) if now >= end)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Paused {
#[serde(default)]
pub queue: Option<PauseRecord>,
#[serde(default)]
pub locks: BTreeMap<String, PauseRecord>,
}
impl Paused {
pub fn is_empty(&self) -> bool {
self.queue.is_none() && self.locks.is_empty()
}
pub fn expire(&mut self, now: u64) -> bool {
let mut changed = false;
if let Some(record) = &self.queue {
if record.expired(now) {
self.queue = None;
changed = true;
}
}
let before = self.locks.len();
self.locks.retain(|_, record| !record.expired(now));
changed |= self.locks.len() != before;
changed
}
pub fn read() -> Self {
let Ok(path) = path() else {
return Self::default();
};
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => return Self::held_by_fault(&path, &e.to_string()),
};
match serde_json::from_str(&text) {
Ok(paused) => paused,
Err(e) => Self::held_by_fault(&path, &e.to_string()),
}
}
fn held_by_fault(path: &std::path::Path, fault: &str) -> Self {
Self {
queue: Some(PauseRecord {
paused_at: crate::sys::now_secs(),
by_pid: 0,
reason: Some(format!("{}: {fault}", path.display())),
until: None,
fault: true,
}),
locks: BTreeMap::new(),
}
}
pub fn write(&self) -> Result<()> {
let path = path()?;
if self.is_empty() {
std::fs::remove_file(&path).ok();
return Ok(());
}
paths::ensure_dir(&paths::runtime_dir()?, 0o700)?;
let bytes = serde_json::to_vec_pretty(self).context("writing the pause record")?;
crate::job::write_atomic(&path, &bytes, 0o600)
}
}
pub fn end_queue_pause(state: &mut crate::daemon::State, record: &PauseRecord, now: u64) {
credit_paused_wait(state, record.paused_at, now);
state.idle_since = Some(std::time::Instant::now());
}
pub fn credit_paused_wait(state: &mut crate::daemon::State, paused_at: u64, now: u64) {
let Some(length) = now.checked_sub(paused_at) else {
return;
};
if length == 0 {
return;
}
for id in state.queue.clone() {
let Some(job) = state.jobs.get_mut(&id) else {
continue;
};
if job.status.state != crate::job::JobState::Queued {
continue;
}
let start = job.status.submitted_at.max(paused_at);
let credit = now.saturating_sub(start);
if credit == 0 {
continue;
}
job.status.queue_pause_secs += credit;
let status = job.status.clone();
if let Ok(dir) = paths::job_dir(&id) {
crate::job::write_status(&dir, &status).ok();
}
}
}
pub fn path() -> Result<std::path::PathBuf> {
Ok(paths::runtime_dir()?.join("paused.json"))
}
fn who(by_pid: i32) -> String {
if by_pid <= 0 {
return "an unknown process".to_string();
}
format!("pid {by_pid}")
}
fn shown_reason(reason: &str) -> String {
crate::job::printable(reason)
}
fn shown_lock(name: &str) -> String {
crate::job::safe_name(name)
}
pub fn queue_reason(record: &PauseRecord) -> String {
if record.fault {
return format!(
"the queue is paused, so qex starts no job. qex could not read its pause record, and \
a record that qex cannot read can hold a pause, so qex holds the queue. The fault: \
{}. Correct that file, or run `qex resume queue` to write a new one.",
record
.reason
.as_deref()
.map(shown_reason)
.unwrap_or_else(|| "unknown".into())
);
}
let mut text = String::from("the queue is paused, so qex starts no job.");
if let Some(reason) = &record.reason {
text.push_str(&format!(" Reason: {}.", shown_reason(reason)));
}
text.push_str(&format!(
" {} paused it at {}. Run `qex resume queue` to start the queue again.",
{
let w = who(record.by_pid);
let mut c = w.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => w,
}
},
crate::sys::clock_text(record.paused_at)
));
text
}
pub fn lock_reason(name: &str) -> String {
format!(
"waits for the lock `{}`, which a person holds",
shown_lock(name)
)
}
pub fn queue_line(record: &PauseRecord, now: u64) -> String {
if record.fault {
return format!(
"PAUSED BY A FAULT: qex could not read its pause record, so it holds the queue · {} · \
correct that file, or run `qex resume queue` to write a new one",
record
.reason
.as_deref()
.map(shown_reason)
.unwrap_or_else(|| "unknown".into())
);
}
let mut text = format!(
"paused since {} ({}) by {}",
crate::sys::clock_text(record.paused_at),
crate::units::format_duration(std::time::Duration::from_secs(
now.saturating_sub(record.paused_at)
)),
who(record.by_pid)
);
match record.until {
Some(end) => text.push_str(&format!(
" · ends at {} (in {})",
crate::sys::clock_text(end),
crate::units::format_duration(std::time::Duration::from_secs(end.saturating_sub(now)))
)),
None => text.push_str(" · NO END: it continues until `qex resume queue`"),
}
if let Some(reason) = &record.reason {
text.push_str(&format!(" · reason: {}", shown_reason(reason)));
}
text
}
pub fn lock_line(name: &str, record: &PauseRecord, held_by: Option<&str>, now: u64) -> String {
let mut text = format!(
"lock `{}`: paused since {} ({}) by {}",
shown_lock(name),
crate::sys::clock_text(record.paused_at),
crate::units::format_duration(std::time::Duration::from_secs(
now.saturating_sub(record.paused_at)
)),
who(record.by_pid)
);
match held_by {
Some(job) => text.push_str(&format!(
" · the job {job} still holds it · qex gives it to you when that job stops"
)),
None => text.push_str(" · it is yours now"),
}
if let Some(reason) = &record.reason {
text.push_str(&format!(" · reason: {}", shown_reason(reason)));
}
text
}
#[cfg(test)]
mod tests {
use super::*;
fn record() -> PauseRecord {
PauseRecord {
paused_at: 1_000,
by_pid: 42,
reason: None,
until: None,
fault: false,
}
}
#[test]
fn a_pause_with_an_end_goes_away_by_itself() {
let mut p = Paused {
queue: Some(PauseRecord {
until: Some(1_100),
..record()
}),
..Default::default()
};
p.locks.insert(
"gpu0".into(),
PauseRecord {
until: Some(2_000),
..record()
},
);
assert!(!p.expire(1_099), "the pause must stay before its end");
assert!(p.queue.is_some());
assert!(p.expire(1_100), "the pause must go away at its end");
assert!(p.queue.is_none(), "the queue must operate again");
assert!(
p.locks.contains_key("gpu0"),
"a lock with a later end must stay"
);
assert!(p.expire(2_000));
assert!(p.is_empty());
}
#[test]
fn a_pause_with_no_end_stays() {
let mut p = Paused {
queue: Some(record()),
..Default::default()
};
assert!(!p.expire(9_999_999));
assert!(p.queue.is_some());
}
#[test]
fn the_reason_of_a_paused_job_does_not_change_with_time() {
let r = record();
assert_eq!(queue_reason(&r), queue_reason(&r));
assert!(queue_reason(&r).contains("the queue is paused"));
assert!(
queue_reason(&r).contains("qex resume queue"),
"the reason must give the remedy"
);
assert!(
!queue_reason(&r).contains("ago"),
"the reason must hold no elapsed time"
);
}
#[test]
fn a_pause_with_no_end_says_so() {
let line = queue_line(&record(), 1_360);
assert!(line.contains("NO END"), "got: {line}");
assert!(line.contains("6m"), "the line must give the length: {line}");
}
#[test]
fn every_report_of_a_pause_names_who_asked_for_it() {
let r = record();
assert_eq!(r.by_pid, 42, "the helper must give a pid to look for");
let line = queue_line(&r, 1_360);
assert!(
line.contains("pid 42"),
"the queue line must say who: {line}"
);
let reason = queue_reason(&r);
assert!(
reason.contains("42"),
"the reason of each queued job must say who: {reason}"
);
let lock = lock_line("gpu0", &r, None, 1_360);
assert!(
lock.contains("pid 42"),
"the lock line must say who: {lock}"
);
}
#[test]
fn a_record_that_qex_cannot_read_holds_the_queue() {
let paused =
Paused::held_by_fault(std::path::Path::new("/x/paused.json"), "expected value");
let record = paused.queue.as_ref().expect("the queue must be paused");
assert!(record.fault);
assert!(!record.expired(9_999_999), "such a pause has no end");
let reason = queue_reason(record);
assert!(reason.contains("could not read"), "got: {reason}");
assert!(reason.contains("/x/paused.json"), "got: {reason}");
assert!(reason.contains("qex resume queue"), "got: {reason}");
assert!(
!reason.contains("A person or an agent paused it"),
"no person asked for this pause: {reason}"
);
let line = queue_line(record, 0);
assert!(line.contains("PAUSED BY A FAULT"), "got: {line}");
}
#[test]
fn a_field_that_this_version_does_not_know_is_ignored() {
let text = r#"{"queue":{"paused_at":10,"by_pid":7,"reason":null,"until":null,
"paused_by_user":"someone"},"locks":{},"maintenance":true}"#;
let back: Paused = serde_json::from_str(text).expect("an unknown field must be ignored");
let record = back.queue.expect("the pause must hold");
assert_eq!(record.paused_at, 10);
assert!(!record.fault);
}
#[test]
fn the_record_survives_the_json() {
let mut p = Paused {
queue: Some(PauseRecord {
reason: Some("recording a demo".into()),
until: Some(2_000),
..record()
}),
..Default::default()
};
p.locks.insert("gpu0".into(), record());
let text = serde_json::to_string(&p).unwrap();
let back: Paused = serde_json::from_str(&text).unwrap();
assert_eq!(back, p);
}
}