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, Serialize, Deserialize)]
pub struct Sample {
pub max_rss: u64,
pub cpu_secs: f64,
pub elapsed_secs: u64,
pub at: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
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"))
}
pub fn load() -> Store {
let Ok(path) = store_path() else {
return Store::default();
};
let Ok(text) = std::fs::read_to_string(path) else {
return Store::default();
};
serde_json::from_str(&text).unwrap_or_default()
}
pub fn record(spec: &JobSpec, status: &JobStatus) {
if status.state != crate::job::JobState::Completed {
return;
}
if status.usage.max_rss == 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 mut store = load();
let entry = store
.commands
.entry(key(&spec.cwd, &spec.command))
.or_default();
entry.name = spec.name.clone();
entry.samples.push(Sample {
max_rss: status.usage.max_rss,
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);
if let Ok(bytes) = serde_json::to_vec_pretty(&store) {
crate::job::write_atomic(&path, &bytes, 0o600).ok();
}
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 peak_mem = entry.samples.iter().map(|s| s.max_rss).max().unwrap_or(0);
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: entry.samples.len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(max_rss: u64, cpu_secs: f64, elapsed_secs: u64) -> Sample {
Sample {
max_rss,
cpu_secs,
elapsed_secs,
at: 0,
}
}
fn dir() -> std::path::PathBuf {
std::path::PathBuf::from("/project")
}
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 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");
}
}