use crate::job::JobStatus;
use crate::paths;
use crate::spec::JobSpec;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
const SAMPLES: usize = 5;
const MIN_MEMORY: u64 = 64 << 20;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Measurement {
#[default]
Peak,
LowerBound,
Unknown(String),
}
impl Serialize for Measurement {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(match self {
Measurement::Peak => "peak",
Measurement::LowerBound => "lower-bound",
Measurement::Unknown(word) => word,
})
}
}
impl<'de> Deserialize<'de> for Measurement {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let word = String::deserialize(deserializer)?;
Ok(match word.as_str() {
"peak" => Measurement::Peak,
"lower-bound" => Measurement::LowerBound,
_ => Measurement::Unknown(word),
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Sample {
pub kind: Measurement,
pub max_rss: u64,
pub cpu_secs: f64,
pub elapsed_secs: u64,
pub at: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Entry {
pub name: String,
pub samples: Vec<Sample>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Store {
#[serde(default)]
pub commands: BTreeMap<String, Entry>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Suggestion {
pub cpu: u64,
pub mem: u64,
pub samples: usize,
}
pub fn key(cwd: &std::path::Path, command: &[String]) -> String {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in cwd.to_string_lossy().as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash ^= 0xfe;
hash = hash.wrapping_mul(0x100000001b3);
for part in command {
for byte in part.as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash ^= 0xff;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
fn store_path() -> anyhow::Result<std::path::PathBuf> {
Ok(paths::state_dir()?.join("usage.json"))
}
enum OnDisk {
Missing,
Unreadable,
Whole(Store),
Damaged(Store),
}
fn read_store(path: &std::path::Path) -> OnDisk {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return OnDisk::Missing,
Err(_) => return OnDisk::Unreadable,
};
match serde_json::from_str(&text) {
Ok(store) => OnDisk::Whole(store),
Err(_) => OnDisk::Damaged(salvage(&text)),
}
}
fn salvage(text: &str) -> Store {
let mut store = Store::default();
let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
return store;
};
let Some(commands) = value.get("commands").and_then(|c| c.as_object()) else {
return store;
};
for (key, entry) in commands {
let name = entry
.get("name")
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string();
let samples: Vec<Sample> = entry
.get("samples")
.and_then(|s| s.as_array())
.map(|list| {
list.iter()
.filter_map(|s| serde_json::from_value(s.clone()).ok())
.collect()
})
.unwrap_or_default();
if samples.is_empty() {
continue;
}
store.commands.insert(key.clone(), Entry { name, samples });
}
store
}
pub fn load() -> Store {
let Ok(path) = store_path() else {
return Store::default();
};
match read_store(&path) {
OnDisk::Missing | OnDisk::Unreadable => Store::default(),
OnDisk::Whole(store) | OnDisk::Damaged(store) => store,
}
}
pub fn record(spec: &JobSpec, status: &JobStatus) {
if status.state != crate::job::JobState::Completed || status.usage.max_rss == 0 {
return;
}
add(spec, status, Measurement::Peak, status.usage.max_rss);
}
fn add(spec: &JobSpec, status: &JobStatus, kind: Measurement, bytes: u64) {
if bytes == 0 {
return;
}
let Ok(path) = store_path() else { return };
let Ok(dir) = paths::state_dir() else { return };
if paths::ensure_dir(&dir, 0o700).is_err() {
return;
}
let lock_path = dir.join("usage.lock");
let Ok(lock) = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
else {
return;
};
use std::os::unix::io::AsRawFd;
unsafe {
libc::flock(lock.as_raw_fd(), libc::LOCK_EX);
}
let against = spec.learn_key.as_deref().unwrap_or(&spec.command);
let mut store = match read_store(&path) {
OnDisk::Missing => Store::default(),
OnDisk::Whole(store) => store,
OnDisk::Unreadable => {
crate::daemon::log(&format!(
"qex could not read {} and did not record the measurement of \
this job. The file stays as it is.",
path.display()
));
release(&lock);
return;
}
OnDisk::Damaged(salvaged) => {
let aside =
path.with_file_name(format!("usage.json.corrupt-{}", crate::sys::now_secs()));
if std::fs::rename(&path, &aside).is_err() {
release(&lock);
return;
}
crate::daemon::log(&format!(
"qex could not read every part of {}. The file moved to {}, so \
that no write destroys it, and the store keeps the entries that \
qex could read ({} of them). The claims of the other commands \
come back as their jobs complete.",
path.display(),
aside.display(),
salvaged.commands.len()
));
salvaged
}
};
let entry = store.commands.entry(key(&spec.cwd, against)).or_default();
entry.name = spec.name.clone();
entry.samples.push(Sample {
kind,
max_rss: bytes,
cpu_secs: status.usage.cpu_secs,
elapsed_secs: status.elapsed().map(|d| d.as_secs()).unwrap_or(0),
at: crate::sys::now_secs(),
});
let extra = entry.samples.len().saturating_sub(SAMPLES);
entry.samples.drain(..extra);
write_store(&path, &store, &lock);
}
fn write_store(path: &std::path::Path, store: &Store, lock: &std::fs::File) {
if let Ok(bytes) = serde_json::to_vec_pretty(store) {
crate::job::write_atomic(path, &bytes, 0o600).ok();
}
release(lock);
}
fn release(lock: &std::fs::File) {
use std::os::unix::io::AsRawFd;
unsafe {
libc::flock(lock.as_raw_fd(), libc::LOCK_UN);
}
}
pub fn suggest(
store: &Store,
cwd: &std::path::Path,
command: &[String],
margin: f64,
) -> Option<Suggestion> {
let entry = store.commands.get(&key(cwd, command))?;
if entry.samples.is_empty() {
return None;
}
let peaks: Vec<u64> = entry
.samples
.iter()
.filter(|s| s.kind == Measurement::Peak && s.max_rss > 0)
.map(|s| s.max_rss)
.collect();
let peak_mem = peaks.iter().copied().max()?;
let mem = ((peak_mem as f64 * margin) as u64).max(MIN_MEMORY);
let cores = entry
.samples
.iter()
.map(|s| {
s.cpu_secs / s.elapsed_secs.max(1) as f64
})
.fold(0.0f64, f64::max);
let cpu = (cores * margin).ceil().max(1.0) as u64;
Some(Suggestion {
cpu,
mem,
samples: peaks.len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(max_rss: u64, cpu_secs: f64, elapsed_secs: u64) -> Sample {
Sample {
kind: Measurement::Peak,
max_rss,
cpu_secs,
elapsed_secs,
at: 0,
}
}
fn lower_bound(max_rss: u64) -> Sample {
Sample {
kind: Measurement::LowerBound,
max_rss,
cpu_secs: 1.0,
elapsed_secs: 1,
at: 0,
}
}
fn dir() -> std::path::PathBuf {
std::path::PathBuf::from("/project")
}
fn spec_for(command: &str) -> JobSpec {
JobSpec {
id: uuid::Uuid::new_v4(),
name: command.into(),
cwd: "/project".into(),
command: vec![command.into()],
env: Default::default(),
cpu: 1,
mem: 4 << 30,
timeout: None,
max_queue_time: None,
tags: vec![],
priority: 0,
env_capture: crate::config::EnvCapture::None,
claim_source: "explicit".into(),
learn_key: None,
group: None,
group_name: None,
claims: Default::default(),
locks: vec![],
retries: 0,
nice: None,
needs: vec![],
after: vec![],
submitted_at: 0,
dedupe_key: None,
dedupe_window: 0,
}
}
fn store_with(command: &[&str], samples: Vec<Sample>) -> Store {
let cmd: Vec<String> = command.iter().map(|s| s.to_string()).collect();
let mut store = Store::default();
store.commands.insert(
key(&dir(), &cmd),
Entry {
name: "test".into(),
samples,
},
);
store
}
#[test]
fn with_no_measurement_there_is_no_claim() {
let store = Store::default();
assert_eq!(suggest(&store, &dir(), &["cargo".into()], 1.5), None);
}
#[test]
fn the_claim_uses_the_largest_measurement() {
let store = store_with(
&["cargo", "test"],
vec![
sample(100 << 20, 1.0, 10),
sample(400 << 20, 1.0, 10),
sample(200 << 20, 1.0, 10),
],
);
let cmd: Vec<String> = vec!["cargo".into(), "test".into()];
let s = suggest(&store, &dir(), &cmd, 1.5).unwrap();
assert_eq!(s.mem, (400 << 20) * 3 / 2, "400MB and one half");
assert_eq!(s.samples, 3);
}
#[test]
fn the_cores_come_from_the_cpu_time_and_the_elapsed_time() {
let cmd: Vec<String> = vec!["make".into()];
let store = store_with(&["make"], vec![sample(1 << 20, 20.0, 10)]);
assert_eq!(suggest(&store, &dir(), &cmd, 1.0).unwrap().cpu, 2);
let store = store_with(&["make"], vec![sample(165 << 20, 1.9, 19)]);
let s = suggest(&store, &dir(), &cmd, 1.5).unwrap();
assert_eq!(s.cpu, 1, "a job that waits needs one core");
assert!(
s.mem < (300 << 20),
"the claim must be near the measurement, and it was {}",
crate::units::format_size(s.mem)
);
}
#[test]
fn a_small_measurement_gives_the_smallest_useful_claim() {
let cmd: Vec<String> = vec!["true".into()];
let store = store_with(&["true"], vec![sample(1 << 20, 0.0, 0)]);
let s = suggest(&store, &dir(), &cmd, 1.5).unwrap();
assert_eq!(s.mem, MIN_MEMORY);
assert_eq!(s.cpu, 1);
}
#[test]
fn two_commands_have_two_records() {
let build: Vec<String> = vec!["cargo".into(), "build".into()];
let test: Vec<String> = vec!["cargo".into(), "test".into()];
assert_ne!(key(&dir(), &build), key(&dir(), &test));
let store = store_with(&["cargo", "build"], vec![sample(1 << 30, 1.0, 1)]);
assert!(suggest(&store, &dir(), &build, 1.5).is_some());
assert!(
suggest(&store, &dir(), &test, 1.5).is_none(),
"`cargo test` must not use the record of `cargo build`"
);
}
#[test]
fn the_key_separates_the_arguments() {
let joined: Vec<String> = vec!["a b".into()];
let split: Vec<String> = vec!["a".into(), "b".into()];
assert_ne!(key(&dir(), &joined), key(&dir(), &split));
}
#[test]
fn one_command_in_two_directories_has_two_records() {
let cmd: Vec<String> = vec!["cargo".into(), "test".into()];
let small = std::path::PathBuf::from("/home/me/small-library");
let large = std::path::PathBuf::from("/home/me/large-program");
assert_ne!(key(&small, &cmd), key(&large, &cmd));
let mut store = Store::default();
store.commands.insert(
key(&small, &cmd),
Entry {
name: "test".into(),
samples: vec![sample(100 << 20, 1.0, 10)],
},
);
assert!(suggest(&store, &small, &cmd, 1.5).is_some());
assert!(
suggest(&store, &large, &cmd, 1.5).is_none(),
"a different directory must not use this record"
);
}
#[test]
fn every_line_of_a_fan_out_shares_one_record() {
let template: Vec<String> = vec!["./process".into(), "{}".into()];
let line_a: Vec<String> = vec!["./process".into(), "a.csv".into()];
let line_b: Vec<String> = vec!["./process".into(), "b.csv".into()];
assert_ne!(key(&dir(), &line_a), key(&dir(), &line_b));
assert_eq!(key(&dir(), &template), key(&dir(), &template));
let store = store_with(&["./process", "{}"], vec![sample(400 << 20, 1.0, 10)]);
assert!(
suggest(&store, &dir(), &line_b, 1.5).is_none(),
"the command of the line must not reach the record"
);
let s = suggest(&store, &dir(), &template, 1.5).unwrap();
assert_eq!(s.mem, (400 << 20) * 3 / 2);
}
#[test]
fn the_largest_evidence_wins_whatever_its_kind() {
let cmd: Vec<String> = vec!["train".into()];
let store = store_with(
&["train"],
vec![lower_bound(2 << 30), sample(6 << 30, 1.0, 10)],
);
let s = suggest(&store, &dir(), &cmd, 1.5).unwrap();
assert_eq!(s.mem, 9 << 30, "6GB and one half");
assert_eq!(s.samples, 1, "the bound is not evidence for the claim");
}
#[test]
fn the_count_of_the_evidence_counts_the_peaks_alone() {
let cmd: Vec<String> = vec!["train".into()];
let store = store_with(
&["train"],
vec![
sample(1 << 30, 1.0, 10),
sample(2 << 30, 1.0, 10),
lower_bound(8 << 30),
sample(0, 1.0, 10),
],
);
let s = suggest(&store, &dir(), &cmd, 1.5).unwrap();
assert_eq!(s.samples, 2, "two peaks stand behind this claim");
}
#[test]
fn a_kind_of_a_later_qex_keeps_its_word() {
let text = r#"{"kind":"ceiling","max_rss":1,"cpu_secs":0.0,"elapsed_secs":0,"at":0}"#;
let s: Sample = serde_json::from_str(text).unwrap();
assert_eq!(s.kind, Measurement::Unknown("ceiling".into()));
let out = serde_json::to_string(&s).unwrap();
assert!(
out.contains(r#""kind":"ceiling""#),
"the word must come back unchanged: {out}"
);
}
#[test]
fn one_entry_of_the_wrong_shape_leaves_the_other_entries() {
let good = r#"{"name":"t","samples":[{"kind":"peak","max_rss":1073741824,"cpu_secs":1.0,"elapsed_secs":10,"at":0}]}"#;
let bad = r#"{"name":"t","samples":[{"kind":3,"max_rss":2,"cpu_secs":1.0,"elapsed_secs":10,"at":0}]}"#;
let text = format!(r#"{{"commands":{{"good":{good},"damaged":{bad}}}}}"#);
assert!(serde_json::from_str::<Store>(&text).is_err());
let store = salvage(&text);
assert_eq!(
store.commands["good"].samples[0].max_rss,
1 << 30,
"the entry beside the damage must stay"
);
assert!(
!store.commands.contains_key("damaged"),
"an entry with no readable sample says nothing"
);
}
#[test]
fn one_sample_of_the_wrong_shape_leaves_the_peaks_beside_it() {
let text = r#"{"commands":{"x":{"name":"t","samples":[
{"kind":3,"max_rss":2,"cpu_secs":1.0,"elapsed_secs":10,"at":0},
{"kind":"peak","max_rss":1073741824,"cpu_secs":1.0,"elapsed_secs":10,"at":0}]}}}"#;
let store = salvage(text);
let peaks: Vec<u64> = store.commands["x"]
.samples
.iter()
.filter(|s| s.kind == Measurement::Peak)
.map(|s| s.max_rss)
.collect();
assert_eq!(peaks, vec![1 << 30], "the peak beside the damage must stay");
}
#[test]
fn a_file_that_is_not_json_salvages_nothing() {
assert!(salvage("").commands.is_empty());
assert!(salvage(r#"{"commands":{"x":"#).commands.is_empty());
assert!(salvage("not json").commands.is_empty());
}
#[test]
fn a_write_over_a_damaged_file_quarantines_it_and_keeps_what_it_can() {
use crate::testutil::{env_lock, EnvVar};
let _guard = env_lock();
let dir = std::env::temp_dir().join(format!("qex-usage-corrupt-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
let _env = EnvVar::set("XDG_STATE_HOME", dir.to_str().unwrap());
let earlier: Vec<String> = vec!["earlier".into()];
let good = format!(
r#""{}":{{"name":"earlier","samples":[{{"kind":"peak","max_rss":1073741824,"cpu_secs":1.0,"elapsed_secs":10,"at":0}}]}}"#,
key(&std::path::PathBuf::from("/project"), &earlier)
);
let bad = r#""damaged":{"name":"t","samples":[{"kind":3}]}"#;
let path = store_path().unwrap();
crate::paths::ensure_dir(path.parent().unwrap(), 0o700).unwrap();
std::fs::write(&path, format!(r#"{{"commands":{{{good},{bad}}}}}"#)).unwrap();
let spec = spec_for("train");
let mut done = crate::job::JobStatus::new(&spec);
done.state = crate::job::JobState::Completed;
done.usage.max_rss = 2 << 30;
record(&spec, &done);
let aside: Vec<_> = std::fs::read_dir(path.parent().unwrap())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("usage.json.corrupt-")
})
.collect();
assert_eq!(aside.len(), 1, "the damaged file must move aside");
let kept = std::fs::read_to_string(aside[0].path()).unwrap();
assert!(
kept.contains(r#""kind":3"#),
"the quarantine must hold the bytes that qex could not read"
);
let store = load();
let old = &store.commands[&key(&std::path::PathBuf::from("/project"), &earlier)];
assert_eq!(
old.samples[0].max_rss,
1 << 30,
"the entry that qex could read must survive the write"
);
let new = &store.commands[&key(&spec.cwd, &spec.command)];
assert_eq!(new.samples[0].max_rss, 2 << 30);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_file_that_qex_cannot_read_takes_no_write() {
use crate::testutil::{env_lock, EnvVar};
use std::os::unix::fs::PermissionsExt;
if unsafe { libc::geteuid() } == 0 {
return;
}
let _guard = env_lock();
let dir = std::env::temp_dir().join(format!("qex-usage-noread-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
let _env = EnvVar::set("XDG_STATE_HOME", dir.to_str().unwrap());
let path = store_path().unwrap();
crate::paths::ensure_dir(path.parent().unwrap(), 0o700).unwrap();
std::fs::write(&path, r#"{"commands":{}}"#).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
let spec = spec_for("train");
let mut done = crate::job::JobStatus::new(&spec);
done.state = crate::job::JobState::Completed;
done.usage.max_rss = 2 << 30;
record(&spec, &done);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(
load().commands.is_empty(),
"no write may land on a file that qex could not read"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_earlier_file_keeps_its_meaning() {
let text = r#"{"commands":{"x":{"name":"t","samples":[
{"max_rss":1073741824,"cpu_secs":1.0,"elapsed_secs":10,"at":0}]}}}"#;
let store: Store = serde_json::from_str(text).unwrap();
let sample = &store.commands["x"].samples[0];
assert_eq!(sample.kind, Measurement::Peak);
assert_eq!(sample.max_rss, 1 << 30);
}
#[test]
fn a_file_with_a_bound_of_an_earlier_qex_still_gives_its_peaks() {
let text = r#"{"commands":{"x":{"name":"t","samples":[
{"kind":"peak","max_rss":1073741824,"cpu_secs":1.0,"elapsed_secs":10,"at":0},
{"kind":"lower-bound","max_rss":8589934592,"cpu_secs":1.0,"elapsed_secs":10,"at":1}]}}}"#;
let store: Store = serde_json::from_str(text).expect("a file of an earlier qex must load");
assert_eq!(
store.commands["x"].samples.len(),
2,
"each sample must load, so that no peak goes away"
);
let peaks: Vec<u64> = store.commands["x"]
.samples
.iter()
.filter(|s| s.kind == Measurement::Peak)
.map(|s| s.max_rss)
.collect();
assert_eq!(peaks, vec![1 << 30], "the peak of the old file must stay");
}
#[test]
fn a_file_that_this_version_cannot_read_in_full_keeps_its_peaks() {
let peak =
r#"{"kind":"peak","max_rss":1073741824,"cpu_secs":1.0,"elapsed_secs":10,"at":0}"#;
for (what, sample) in [
(
"a kind of a later qex",
r#"{"kind":"ceiling","max_rss":8589934592,"cpu_secs":1.0,"elapsed_secs":10,"at":1}"#,
),
(
"a sample with no kind",
r#"{"max_rss":2,"cpu_secs":1.0,"elapsed_secs":10,"at":1}"#,
),
(
"a sample that is missing a field",
r#"{"kind":"peak","max_rss":2}"#,
),
("a sample with no field at all", r#"{}"#),
] {
let text =
format!(r#"{{"commands":{{"x":{{"name":"t","samples":[{peak},{sample}]}}}}}}"#);
let store: Store = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("{what} must not empty the store: {e}"));
let peaks: Vec<u64> = store.commands["x"]
.samples
.iter()
.filter(|s| s.kind == Measurement::Peak)
.map(|s| s.max_rss)
.collect();
assert!(
peaks.contains(&(1 << 30)),
"{what} must leave the peak of the file: {peaks:?}"
);
}
}
#[test]
fn an_entry_that_this_version_cannot_read_in_full_keeps_the_other_commands() {
let good = r#"{"name":"t","samples":[{"kind":"peak","max_rss":1073741824,"cpu_secs":1.0,"elapsed_secs":10,"at":0}]}"#;
for (what, entry) in [
("an entry with no name", r#"{"samples":[]}"#),
("an entry where the samples went", r#"{"name":"t"}"#),
("an entry with no field at all", r#"{}"#),
(
"an entry with a field of a later qex",
r#"{"name":"t","samples":[],"ceiling":42}"#,
),
] {
let text = format!(r#"{{"commands":{{"good":{good},"damaged":{entry}}}}}"#);
let store: Store = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("{what} must not empty the store: {e}"));
let peaks: Vec<u64> = store.commands["good"]
.samples
.iter()
.filter(|s| s.kind == Measurement::Peak)
.map(|s| s.max_rss)
.collect();
assert_eq!(
peaks,
vec![1 << 30],
"{what} must leave the peak of the command beside it"
);
}
}
#[test]
fn a_store_with_a_field_of_a_later_qex_keeps_its_commands() {
let good = r#"{"name":"t","samples":[{"kind":"peak","max_rss":1073741824,"cpu_secs":1.0,"elapsed_secs":10,"at":0}]}"#;
let text = format!(r#"{{"commands":{{"good":{good}}},"written_by":"a later qex"}}"#);
let store: Store = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("a field of a later qex must not empty the store: {e}"));
assert_eq!(store.commands["good"].samples[0].max_rss, 1 << 30);
let empty: Store = serde_json::from_str(r#"{"written_by":"a later qex"}"#)
.expect("a file with no commands field must read as an empty store");
assert!(empty.commands.is_empty());
}
#[test]
fn a_command_with_a_bound_and_no_peak_gives_no_claim() {
let cmd: Vec<String> = vec!["train".into()];
let store = store_with(&["train"], vec![lower_bound(8 << 30)]);
assert!(
suggest(&store, &dir(), &cmd, 1.5).is_none(),
"a bound is not a measurement, so it must give no claim at all"
);
}
#[test]
fn a_peak_of_zero_bytes_gives_no_claim() {
let cmd: Vec<String> = vec!["train".into()];
for (what, text) in [
(
"a sample with a kind and no number",
r#"{"commands":{"KEY":{"name":"t","samples":[{"kind":"peak"}]}}}"#,
),
(
"a sample with no field at all",
r#"{"commands":{"KEY":{"name":"t","samples":[{}]}}}"#,
),
] {
let text = text.replace("KEY", &key(&dir(), &cmd));
let store: Store = serde_json::from_str(&text).unwrap();
assert!(
suggest(&store, &dir(), &cmd, 1.5).is_none(),
"{what} measures nothing, so it must give no claim at all"
);
}
}
#[test]
fn a_peak_of_zero_bytes_leaves_the_claim_of_the_true_peaks() {
let cmd: Vec<String> = vec!["train".into()];
let store = store_with(
&["train"],
vec![sample(0, 1.0, 10), sample(1 << 30, 1.0, 10)],
);
let s = suggest(&store, &dir(), &cmd, 1.5).expect("the peak must give a claim");
assert_eq!(
s.mem,
(1 << 30) * 3 / 2,
"the claim must come from the peak that measures memory"
);
}
#[test]
fn a_bound_of_an_earlier_qex_gives_no_claim() {
let cmd: Vec<String> = vec!["train".into()];
let store = store_with(
&["train"],
vec![lower_bound(8 << 30), sample(1 << 30, 1.0, 10)],
);
let s = suggest(&store, &dir(), &cmd, 1.5).expect("the peaks must give a claim");
assert_eq!(
s.mem,
(1 << 30) * 3 / 2,
"the claim must come from the peak alone, and not from the bound"
);
}
#[test]
fn a_job_that_did_not_complete_teaches_the_learner_nothing() {
use crate::testutil::{env_lock, EnvVar};
let _guard = env_lock();
let dir = std::env::temp_dir().join(format!("qex-usage-oom-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
let _env = EnvVar::set("XDG_STATE_HOME", dir.to_str().unwrap());
let mut spec = spec_for("train");
for state in [
crate::job::JobState::Oom,
crate::job::JobState::Killed,
crate::job::JobState::Failed,
] {
let mut status = crate::job::JobStatus::new(&spec);
status.state = state;
status.usage.max_rss = 3 << 30;
record(&spec, &status);
assert!(
!load().commands.contains_key(&key(&spec.cwd, &spec.command)),
"the state {state:?} must teach the learner nothing"
);
}
spec.command = vec!["good".into()];
let mut done = crate::job::JobStatus::new(&spec);
done.state = crate::job::JobState::Completed;
done.usage.max_rss = 2 << 30;
record(&spec, &done);
let store = load();
let entry = &store.commands[&key(&spec.cwd, &spec.command)];
assert_eq!(entry.samples.len(), 1);
assert_eq!(entry.samples[0].kind, Measurement::Peak);
assert_eq!(entry.samples[0].max_rss, 2 << 30);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_key_does_not_hold_the_command() {
let secret: Vec<String> = vec!["deploy".into(), "--token=SECRET123".into()];
let k = key(&dir(), &secret);
assert!(!k.contains("SECRET"), "the key holds the command: {k}");
assert!(!k.contains("project"), "the key holds the directory: {k}");
assert_eq!(k.len(), 16, "the key is a hash of a fixed length");
}
}