use crate::job::JobStatus;
use crate::paths;
#[cfg(test)]
use crate::spec::JobSpec;
use serde::{Deserialize, Serialize};
const MAX_LINES: usize = 20_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub id: uuid::Uuid,
pub name: String,
pub submitted_at: u64,
#[serde(default)]
pub removed_at: Option<u64>,
#[serde(default)]
pub final_state: Option<String>,
}
fn path() -> anyhow::Result<std::path::PathBuf> {
Ok(paths::state_dir()?.join("history.jsonl"))
}
fn append(entry: &Entry) {
let Ok(file_path) = path() else { return };
let Ok(dir) = paths::state_dir() else { return };
if paths::ensure_dir(&dir, 0o700).is_err() {
return;
}
let Ok(mut line) = serde_json::to_string(entry) else {
return;
};
line.push('\n');
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(&file_path)
{
file.write_all(line.as_bytes()).ok();
}
}
#[cfg(test)]
pub fn record_submit(spec: &JobSpec) {
record_submit_for(&spec.id, &spec.name, spec.submitted_at);
}
pub fn record_submit_for(id: &uuid::Uuid, name: &str, submitted_at: u64) {
append(&Entry {
id: *id,
name: name.to_string(),
submitted_at,
removed_at: None,
final_state: None,
});
}
pub fn record_removed(status: &JobStatus) {
append(&Entry {
id: status.id,
name: status.name.clone(),
submitted_at: status.submitted_at,
removed_at: Some(crate::sys::now_secs()),
final_state: Some(status.state.to_string()),
});
}
pub fn load() -> Vec<Entry> {
let Ok(file_path) = path() else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(file_path) else {
return Vec::new();
};
text.lines()
.filter_map(|l| serde_json::from_str::<Entry>(l).ok())
.collect()
}
pub fn lookup(id: uuid::Uuid) -> Option<Entry> {
let mut found: Option<Entry> = None;
for entry in load() {
if entry.id != id {
continue;
}
found = Some(match found {
None => entry,
Some(mut earlier) => {
if entry.removed_at.is_some() {
earlier.removed_at = entry.removed_at;
earlier.final_state = entry.final_state;
}
earlier
}
});
}
found
}
pub fn describe_missing(id: uuid::Uuid) -> String {
match lookup(id) {
None => format!(
"there is no job with the id {id}, and qex has no record of one. \
Test the id, or submit the job."
),
Some(entry) => match (entry.removed_at, entry.final_state) {
(Some(when), Some(state)) => format!(
"the job {id} ({}) existed and its state was `{state}`. Something deleted \
its record {} ago, with `qex clean` or by a deletion of the state \
directory. The work of that job HAPPENED. Do not submit it again unless \
you want to repeat the work.",
entry.name,
crate::units::format_duration(std::time::Duration::from_secs(
crate::sys::now_secs().saturating_sub(when)
))
),
(Some(when), None) => format!(
"the job {id} ({}) existed. Something deleted its record {} ago. qex does \
not know how that job ended.",
entry.name,
crate::units::format_duration(std::time::Duration::from_secs(
crate::sys::now_secs().saturating_sub(when)
))
),
(None, _) => format!(
"the job {id} ({}) existed, and qex accepted it {} ago, but its record is \
gone and qex did not delete it. Something removed the state directory. \
The job may have run.",
entry.name,
crate::units::format_duration(std::time::Duration::from_secs(
crate::sys::now_secs().saturating_sub(entry.submitted_at)
))
),
},
}
}
pub fn prune(cfg: &crate::config::Config) {
let keep_for = cfg
.history_keep()
.unwrap_or(std::time::Duration::from_secs(86400))
.as_secs();
let now = crate::sys::now_secs();
let entries = load();
let kept: Vec<Entry> = entries
.iter()
.filter(|e| {
let at = e.removed_at.unwrap_or(e.submitted_at).max(e.submitted_at);
now.saturating_sub(at) <= keep_for
})
.cloned()
.collect();
let start = kept.len().saturating_sub(MAX_LINES);
let kept = &kept[start..];
if kept.len() == entries.len() {
return;
}
let Ok(file_path) = path() else { return };
let mut text = String::new();
for entry in kept {
if let Ok(line) = serde_json::to_string(entry) {
text.push_str(&line);
text.push('\n');
}
}
crate::job::write_atomic(&file_path, text.as_bytes(), 0o600).ok();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{env_lock, EnvVar};
fn temp_state(tag: &str) -> (std::path::PathBuf, EnvVar) {
let dir = std::env::temp_dir().join(format!("qex-hist-{tag}-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
let guard = EnvVar::set("XDG_STATE_HOME", dir.to_str().unwrap());
(dir, guard)
}
fn spec(id: uuid::Uuid, name: &str) -> JobSpec {
JobSpec {
id,
name: name.into(),
cwd: "/".into(),
command: vec!["true".into()],
env: Default::default(),
cpu: 1,
mem: 1 << 20,
timeout: None,
tags: vec![],
priority: 0,
env_capture: crate::config::EnvCapture::None,
claim_source: "explicit".into(),
group: None,
group_name: None,
locks: vec![],
retries: 0,
needs: vec![],
after: vec![],
submitted_at: crate::sys::now_secs(),
}
}
#[test]
fn an_unknown_id_says_that_qex_never_saw_it() {
let _guard = env_lock();
let (dir, _env) = temp_state("unknown");
let text = describe_missing(uuid::Uuid::new_v4());
assert!(text.contains("no record"), "got: {text}");
assert!(
text.contains("submit"),
"the message must say what to do: {text}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_deleted_record_says_that_the_work_happened() {
let _guard = env_lock();
let (dir, _env) = temp_state("deleted");
let id = uuid::Uuid::new_v4();
record_submit(&spec(id, "build"));
let mut status = crate::job::JobStatus::new(&spec(id, "build"));
status.state = crate::job::JobState::Completed;
record_removed(&status);
let text = describe_missing(id);
assert!(text.contains("existed"), "got: {text}");
assert!(text.contains("completed"), "the state is missing: {text}");
assert!(
text.contains("HAPPENED"),
"the message must say that the work happened: {text}"
);
assert!(
text.contains("Do not submit it again"),
"the message must say what to do: {text}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_old_line_goes_away() {
let _guard = env_lock();
let (dir, _env) = temp_state("prune");
let old_id = uuid::Uuid::new_v4();
let new_id = uuid::Uuid::new_v4();
let mut old = spec(old_id, "old");
old.submitted_at = crate::sys::now_secs() - 3 * 86400;
record_submit(&old);
record_submit(&spec(new_id, "new"));
let cfg = crate::config::Config::default();
assert_eq!(cfg.history.keep, "1d", "the default is one day");
prune(&cfg);
assert!(lookup(old_id).is_none(), "the old line must go away");
assert!(lookup(new_id).is_some(), "the young line must stay");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_record_that_vanished_is_different_from_one_that_qex_deleted() {
let _guard = env_lock();
let (dir, _env) = temp_state("vanished");
let id = uuid::Uuid::new_v4();
record_submit(&spec(id, "train"));
let text = describe_missing(id);
assert!(text.contains("qex did not delete it"), "got: {text}");
assert!(text.contains("may have run"), "got: {text}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_file_never_holds_the_command() {
let _guard = env_lock();
let (dir, _env) = temp_state("secret");
let mut s = spec(uuid::Uuid::new_v4(), "deploy");
s.command = vec!["deploy".into(), "--token=SECRET123".into()];
record_submit(&s);
let text = std::fs::read_to_string(dir.join("qex/history.jsonl")).unwrap();
assert!(
!text.contains("SECRET123"),
"the file holds the command: {text}"
);
std::fs::remove_dir_all(&dir).ok();
}
}