use crate::{paths, sys, units};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EnvCapture {
#[default]
All,
Minimal,
None,
}
impl std::str::FromStr for EnvCapture {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s.trim().to_ascii_lowercase().as_str() {
"all" => Ok(Self::All),
"minimal" => Ok(Self::Minimal),
"none" => Ok(Self::None),
other => Err(format!(
"unknown env capture mode `{other}`; expected all, minimal or none"
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum OversizedPolicy {
#[default]
RunWhenIdle,
Reject,
Queue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EnforceMode {
#[default]
Off,
Soft,
Hard,
}
impl EnforceMode {
pub fn is_on(self) -> bool {
self != Self::Off
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct BudgetConfig {
pub cpu: String,
pub mem: String,
}
impl Default for BudgetConfig {
fn default() -> Self {
Self {
cpu: "75%".into(),
mem: "75%".into(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SystemConfig {
pub reserve_mem: String,
pub max_pressure: f64,
}
impl Default for SystemConfig {
fn default() -> Self {
Self {
reserve_mem: "2GB".into(),
max_pressure: 20.0,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EnforceConfig {
pub mode: EnforceMode,
pub mem_overcommit: f64,
pub use_systemd: bool,
}
impl Default for EnforceConfig {
fn default() -> Self {
Self {
mode: EnforceMode::Off,
mem_overcommit: 1.5,
use_systemd: true,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PeersConfig {
pub enabled: bool,
pub dir: String,
pub stale_after: String,
}
impl Default for PeersConfig {
fn default() -> Self {
Self {
enabled: true,
dir: "/tmp/qex".into(),
stale_after: "30s".into(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct QueueConfig {
pub oversized: OversizedPolicy,
pub settle: String,
}
impl Default for QueueConfig {
fn default() -> Self {
Self {
oversized: OversizedPolicy::RunWhenIdle,
settle: "3s".into(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SubmitConfig {
pub env_capture: EnvCapture,
pub minimal_env: Vec<String>,
}
impl Default for SubmitConfig {
fn default() -> Self {
Self {
env_capture: EnvCapture::All,
minimal_env: ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "TZ"]
.iter()
.map(|s| s.to_string())
.collect(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct DefaultsConfig {
pub cpu: Option<u64>,
pub mem: Option<String>,
pub timeout: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct GcConfig {
pub keep: String,
}
impl Default for GcConfig {
fn default() -> Self {
Self { keep: "1d".into() }
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct HistoryConfig {
pub keep: String,
}
impl Default for HistoryConfig {
fn default() -> Self {
Self { keep: "1d".into() }
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct LearnConfig {
pub enabled: bool,
pub margin: f64,
}
impl Default for LearnConfig {
fn default() -> Self {
Self {
enabled: true,
margin: 1.5,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub budget: BudgetConfig,
pub system: SystemConfig,
pub enforce: EnforceConfig,
pub peers: PeersConfig,
pub queue: QueueConfig,
pub submit: SubmitConfig,
pub defaults: DefaultsConfig,
pub learn: LearnConfig,
pub history: HistoryConfig,
pub gc: GcConfig,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Detail {
Full,
Short,
}
fn config_error(path: &std::path::Path, error: toml::de::Error, detail: Detail) -> anyhow::Error {
let short = anyhow::anyhow!("parsing config file {}: {error}", path.display());
if detail == Detail::Short || !error.message().contains("unknown field") {
return short;
}
anyhow::anyhow!(
"{short}\n\n\
qex refuses a field that it does not know, because a name with a spelling \
fault must not be ignored in silence.\n\n\
This qex is version {}. If that name is an option of a NEWER qex, then the \
file and the program do not agree. Each command that needs this file stops \
until they agree, and qex cannot start a coordinator, so a queue whose \
coordinator retires stays where it is. Your jobs continue, and `qex wait` \
and `qex top` continue. `qex info`, `qex list` and `qex status` continue \
while a coordinator operates. Read `qex help config`.\n\n\
Put a new option in the config file only AFTER the coordinator is the new \
build. The program on the disk is not sufficient: a coordinator operates for \
hours, it holds the code that started it, and it reads this file once, when \
it starts. A new option that you write before that moment has no effect, and \
qex ignores it in silence.\n\n\
To correct it now:\n\
\x20 1. Install the new qex. Do this FIRST: while the old qex is the \
program on the disk, no coordinator can start from this file.\n\
\x20 2. Run `qex info` for the version and the pid of the coordinator. A \
coordinator stops when no job operates; `kill <pid>` changes it at once, and \
the jobs that operate continue.\n\
\x20 3. Run `qex info` again. The version must be the new one.\n\n\
To go back instead, remove that section from {}.",
crate::version::VERSION,
path.display()
)
}
impl Config {
pub fn load() -> Result<Self> {
Self::read(Detail::Full)
}
pub fn load_for_job_record() -> Result<Self> {
Self::read(Detail::Short)
}
fn read(detail: Detail) -> Result<Self> {
let path = paths::config_file()?;
match std::fs::read_to_string(&path) {
Ok(text) => toml::from_str(&text).map_err(|e| config_error(&path, e, detail)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("reading config file {}", path.display())),
}
}
pub fn budget_cpu(&self) -> Result<u64> {
let total = sys::cpu_count();
let n = units::parse_budget(&self.budget.cpu, total, false)
.map_err(|e| anyhow::anyhow!("config [budget] cpu: {e}"))?;
Ok(n.max(1))
}
pub fn budget_mem(&self) -> Result<u64> {
let total = sys::total_memory();
let n = units::parse_budget(&self.budget.mem, total, true)
.map_err(|e| anyhow::anyhow!("config [budget] mem: {e}"))?;
Ok(n.max(64 << 20))
}
pub fn reserve_mem(&self) -> Result<u64> {
units::parse_size(&self.system.reserve_mem)
.map_err(|e| anyhow::anyhow!("config [system] reserve_mem: {e}"))
}
pub fn settle(&self) -> Result<std::time::Duration> {
units::parse_duration(&self.queue.settle)
.map_err(|e| anyhow::anyhow!("config [queue] settle: {e}"))
.map(|d| d.unwrap_or(std::time::Duration::ZERO))
}
pub fn gc_keep(&self) -> Result<std::time::Duration> {
units::parse_duration(&self.gc.keep)
.map_err(|e| anyhow::anyhow!("config [gc] keep: {e}"))
.map(|d| d.unwrap_or(std::time::Duration::from_secs(86400)))
}
pub fn history_keep(&self) -> Result<std::time::Duration> {
units::parse_duration(&self.history.keep)
.map_err(|e| anyhow::anyhow!("config [history] keep: {e}"))
.map(|d| d.unwrap_or(std::time::Duration::from_secs(86400)))
}
pub fn peer_stale_after(&self) -> Result<std::time::Duration> {
units::parse_duration(&self.peers.stale_after)
.map_err(|e| anyhow::anyhow!("config [peers] stale_after: {e}"))
.map(|d| d.unwrap_or(std::time::Duration::from_secs(30)))
}
pub fn default_cpu(&self) -> u64 {
self.defaults.cpu.unwrap_or(1).max(1)
}
pub fn default_mem(&self) -> Result<u64> {
match &self.defaults.mem {
Some(s) => {
units::parse_size(s).map_err(|e| anyhow::anyhow!("config [defaults] mem: {e}"))
}
None => {
let cores = sys::cpu_count().max(1);
let total = sys::total_memory();
Ok((total / cores).max(1 << 28))
}
}
}
pub fn default_timeout(&self) -> Result<Option<std::time::Duration>> {
match &self.defaults.timeout {
Some(s) => units::parse_duration(s)
.map_err(|e| anyhow::anyhow!("config [defaults] timeout: {e}")),
None => Ok(None),
}
}
pub fn validate(&self) -> Result<()> {
self.budget_cpu()?;
self.budget_mem()?;
self.reserve_mem()?;
self.settle()?;
self.peer_stale_after()?;
self.history_keep()?;
self.gc_keep()?;
self.default_mem()?;
self.default_timeout()?;
if self.learn.margin < 1.0 {
anyhow::bail!(
"config [learn] margin is {}. Use a value of 1.0 or more. A smaller value \
gives a claim below the measurement, and the job would then stop.",
self.learn.margin
);
}
if self.enforce.mem_overcommit < 1.0 {
anyhow::bail!(
"config [enforce] mem_overcommit is {}. Use a value of 1.0 or more. \
A smaller value sets memory.max below memory.high. The kernel then \
stops every job that reaches its claim.",
self.enforce.mem_overcommit
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_field_that_qex_does_not_know_gives_the_order_of_the_steps() {
let path = std::path::Path::new("/home/me/.config/qex.toml");
let error = toml::from_str::<Config>("[hooks]\non_stop = [\"true\"]\n").unwrap_err();
let text = config_error(path, error, Detail::Full).to_string();
assert!(
text.contains("unknown field"),
"the message must keep the answer of the parser: {text}"
);
assert!(
text.contains("remove that section"),
"the message must give the way back: {text}"
);
assert!(
text.contains("qex info") && text.contains("continue"),
"the message must say which commands continue: {text}"
);
assert!(
text.contains("while a coordinator operates"),
"the promise about `qex info` must carry its qualifier: {text}"
);
assert!(
text.contains("Do this FIRST"),
"the message must say that the install comes before the kill: {text}"
);
assert!(
text.contains("AFTER the coordinator is the new build"),
"the message must give the order of the steps: {text}"
);
assert!(
text.contains("not sufficient"),
"the message must say that the program on the disk is not sufficient: {text}"
);
assert!(
text.contains(crate::version::VERSION),
"the message must name the version that reads the file: {text}"
);
let other = toml::from_str::<Config>("[budget\n").unwrap_err();
let text = config_error(path, other, Detail::Full).to_string();
assert!(
!text.contains("coordinator"),
"a fault of the form of the file must not give the version lesson: {text}"
);
}
#[test]
fn the_record_of_a_job_takes_the_short_message() {
let path = std::path::Path::new("/home/me/.config/qex.toml");
let error = toml::from_str::<Config>("[hooks]\non_stop = [\"true\"]\n").unwrap_err();
let text = config_error(path, error, Detail::Short).to_string();
assert!(
text.contains("unknown field"),
"the short message must still give the answer of the parser: {text}"
);
assert!(
!text.contains("coordinator"),
"the record of a job must not hold the lesson about the coordinator: {text}"
);
assert!(
text.lines().count() <= 6,
"the short message must fit in a record: {text}"
);
}
#[test]
fn empty_config_is_valid_and_gives_working_defaults() {
let c: Config = toml::from_str("").unwrap();
c.validate().unwrap();
assert_eq!(c.submit.env_capture, EnvCapture::All);
assert_eq!(c.queue.oversized, OversizedPolicy::RunWhenIdle);
assert_eq!(c.enforce.mode, EnforceMode::Off);
assert!(c.budget_cpu().unwrap() >= 1);
assert_eq!(c.default_cpu(), 1);
assert_eq!(c.default_timeout().unwrap(), None);
}
#[test]
fn default_job_size_scales_with_the_machine() {
let c = Config::default();
let cores = sys::cpu_count().max(1);
let expected = (sys::total_memory() / cores).max(1 << 28);
assert_eq!(c.default_mem().unwrap(), expected);
let budget_mem = c.budget_mem().unwrap();
assert!(
c.default_mem().unwrap() <= budget_mem,
"a job of the default size ({}) does not fit the default budget ({})",
units::format_size(c.default_mem().unwrap()),
units::format_size(budget_mem)
);
}
#[test]
fn config_defaults_replace_the_calculated_values() {
let c: Config =
toml::from_str("[defaults]\ncpu = 4\nmem = \"6GB\"\ntimeout = \"30m\"\n").unwrap();
c.validate().unwrap();
assert_eq!(c.default_cpu(), 4);
assert_eq!(c.default_mem().unwrap(), 6 << 30);
assert_eq!(
c.default_timeout().unwrap(),
Some(std::time::Duration::from_secs(1800))
);
}
#[test]
fn documented_config_parses() {
let text = r#"
[budget]
cpu = "75%"
mem = "20GB"
[system]
reserve_mem = "2GB"
max_pressure = 20
[enforce]
mode = "soft"
mem_overcommit = 1.5
use_systemd = true
[peers]
enabled = true
dir = "/tmp/qex"
stale_after = "30s"
[queue]
oversized = "run-when-idle"
settle = "3s"
[submit]
env_capture = "minimal"
minimal_env = ["PATH", "HOME"]
[defaults]
cpu = 1
mem = "1GB"
timeout = "0"
"#;
let c: Config = toml::from_str(text).unwrap();
c.validate().unwrap();
assert_eq!(c.enforce.mode, EnforceMode::Soft);
assert_eq!(c.submit.env_capture, EnvCapture::Minimal);
assert_eq!(c.budget_mem().unwrap(), 20 << 30);
assert_eq!(c.default_timeout().unwrap(), None);
}
#[test]
fn typos_in_config_keys_are_rejected_not_ignored() {
let err = toml::from_str::<Config>("[budget]\ncpuu = 4\n").unwrap_err();
assert!(err.to_string().contains("cpuu"), "got: {err}");
}
#[test]
fn bad_values_are_reported_with_the_offending_section() {
let c: Config = toml::from_str("[budget]\nmem = \"lots\"\n").unwrap();
let err = c.validate().unwrap_err().to_string();
assert!(err.contains("[budget] mem"), "got: {err}");
}
#[test]
fn overcommit_below_one_is_rejected() {
let c: Config = toml::from_str("[enforce]\nmem_overcommit = 0.5\n").unwrap();
assert!(c.validate().is_err());
}
#[test]
fn env_capture_parses_from_cli_strings() {
use std::str::FromStr;
assert_eq!(EnvCapture::from_str("all").unwrap(), EnvCapture::All);
assert_eq!(
EnvCapture::from_str("MINIMAL").unwrap(),
EnvCapture::Minimal
);
assert_eq!(EnvCapture::from_str(" none ").unwrap(), EnvCapture::None);
assert!(EnvCapture::from_str("some").is_err());
}
}