use crate::spec::JobSpec;
use crate::version::is_development;
pub const CAPABILITY_FLOOR: (u32, u32, u32) = (0, 6, 0);
pub enum Floor {
Supported,
Development(String),
Below(String),
}
pub fn check_floor(coordinator_version: &str, coordinator_pid: i32) -> Floor {
if is_development(coordinator_version) {
if parse(coordinator_version) >= CAPABILITY_FLOOR {
return Floor::Supported;
}
return Floor::Development(format!(
"the coordinator (pid {coordinator_pid}) is version {coordinator_version}, which is \
a development build and not a release.\n\n\
qex gives no promise about which options such a coordinator obeys. It still says \
what it can do, and qex still refuses a job with an option that it cannot obey, \
so this is a warning and not an error.\n\n\
The coordinator stops when no job operates, and the next command then starts one \
from the program that you have now. `qex info` gives its pid. To change it now:\n\
\x20 kill {coordinator_pid}\n\n\
The jobs that operate now continue; a new coordinator reads the same records."
));
}
if parse(coordinator_version) >= CAPABILITY_FLOOR {
return Floor::Supported;
}
let (major, minor, patch) = CAPABILITY_FLOOR;
Floor::Below(format!(
"the coordinator (pid {coordinator_pid}) is version {coordinator_version}, and a \
coordinator says what it can do from {major}.{minor}.{patch} and above.\n\n\
That coordinator does not answer the question, so qex cannot learn which options it \
obeys, and it must not let you believe a rule holds when it may not.\n\n\
The coordinator stops when no job operates, and the next command then starts one from \
the program that you have now. To change it now:\n\
\x20 kill {coordinator_pid}\n\n\
The jobs that operate now continue; a new coordinator reads the same records."
))
}
pub const ALL: &[&str] = &[
"dedupe",
"dependencies",
"events",
"groups",
"history",
"learn",
"locks",
"max-queue-time",
"own-job",
"pause",
"politeness",
"pools",
"retries",
];
pub fn parse(version: &str) -> (u32, u32, u32) {
let mut parts = version.trim().split('.').map(|p| {
p.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u32>()
.unwrap_or(0)
});
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
}
pub fn required_by(spec: &JobSpec) -> Vec<&'static str> {
let mut out = Vec::new();
if !spec.needs.is_empty() || !spec.after.is_empty() {
out.push("dependencies");
}
if !spec.locks.is_empty() {
out.push("locks");
}
if !spec.claims.is_empty() {
out.push("pools");
}
if spec.retries > 0 {
out.push("retries");
}
if spec.max_queue_time.is_some() {
out.push("max-queue-time");
}
if spec.group.is_some() {
out.push("groups");
}
if spec.nice.is_some() {
out.push("politeness");
}
if spec.dedupe_key.is_some() {
out.push("dedupe");
}
out
}
pub fn option_for(capability: &'static str) -> &'static str {
match capability {
"locks" => "--lock",
"max-queue-time" => "--max-queue-time",
"pools" => "--gpu, --vram and --claim",
"retries" => "--retries",
"dependencies" => "--needs and --after",
"groups" => "qex pipeline",
"politeness" => "--nice",
"dedupe" => "--dedupe-key",
other => other,
}
}
pub fn check(
have: &[String],
coordinator_version: &str,
coordinator_pid: i32,
spec: &JobSpec,
) -> Result<(), String> {
let missing: Vec<&str> = required_by(spec)
.into_iter()
.filter(|need| !have.iter().any(|h| h == need))
.collect();
if missing.is_empty() {
return Ok(());
}
let options: Vec<&str> = missing.iter().map(|m| option_for(m)).collect();
Err(format!(
"the coordinator (pid {coordinator_pid}) is version {coordinator_version}, and it \
cannot obey {}.\n\n\
qex refuses this job. The coordinator would ignore that option in silence, give \
you a job id, and run the job without the rule that you asked for.\n\n\
The coordinator stops when no job operates, and the next command then starts one \
that can obey. To change it now:\n\
\x20 kill {coordinator_pid}\n\n\
The jobs that operate now continue; a new coordinator reads the same records.",
options.join(" and ")
))
}
#[derive(Debug)]
pub struct StageNeeds {
pub stage: String,
pub needs: Vec<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
EveryPipeline,
Configuration,
}
pub fn check_pipeline(
have: &[String],
coordinator_version: &str,
coordinator_pid: i32,
elsewhere: &[(&'static str, Source)],
stages: &[StageNeeds],
) -> Result<(), String> {
let absent = |need: &'static str| !have.iter().any(|h| h == need);
let mut missing: std::collections::BTreeMap<&'static str, Result<Vec<String>, Source>> =
Default::default();
for (need, source) in elsewhere.iter().filter(|(n, _)| absent(n)) {
missing.entry(need).or_insert(Err(*source));
}
for stage in stages {
for need in stage.needs.iter().copied().filter(|n| absent(n)) {
match missing.entry(need).or_insert_with(|| Ok(Vec::new())) {
Ok(names) => names.push(stage.stage.clone()),
Err(Source::EveryPipeline) => {}
slot @ Err(Source::Configuration) => *slot = Ok(vec![stage.stage.clone()]),
}
}
}
if missing.is_empty() {
return Ok(());
}
let width = missing
.keys()
.map(|need| option_for(need).len())
.max()
.unwrap_or(0);
let mut lines = String::new();
for (need, who) in &missing {
let option = option_for(need);
let reason = match who {
Err(Source::EveryPipeline) => String::from("every pipeline asks for it"),
Err(Source::Configuration) => {
String::from("a default of the configuration, and no stage of this file")
}
Ok(names) if names.len() == 1 => format!("the stage `{}`", names[0]),
Ok(names) => format!(
"the stages {}",
names
.iter()
.map(|n| format!("`{n}`"))
.collect::<Vec<_>>()
.join(", ")
),
};
lines.push_str(&format!("\x20 {option:width$} {reason}\n"));
}
Err(format!(
"the coordinator (pid {coordinator_pid}) is version {coordinator_version}, and it \
cannot obey:\n\n\
{lines}\n\
qex refuses this pipeline, and it started no job. The coordinator would ignore each \
option in silence, give you an id for every stage, and run the pipeline without the \
rules that you asked for.\n\n\
The coordinator stops when no job operates, and the next command then starts one \
that can obey. To change it now:\n\
\x20 kill {coordinator_pid}\n\n\
The jobs that operate now continue; a new coordinator reads the same records."
))
}
pub fn check_command(
have: &[String],
coordinator_version: &str,
coordinator_pid: i32,
capability: &str,
command: &str,
danger: &str,
) -> Result<(), String> {
if have.iter().any(|h| h == capability) {
return Ok(());
}
Err(format!(
"the coordinator (pid {coordinator_pid}) is version {coordinator_version}, and it \
cannot obey `{command}`.\n\n\
qex refuses this command. {danger}\n\n\
The coordinator stops when no job operates, and the next command then starts one \
that can obey. To change it now:\n\
\x20 kill {coordinator_pid}\n\n\
The jobs that operate now continue; a new coordinator reads the same records."
))
}
#[cfg(test)]
mod tests {
use super::*;
fn spec() -> JobSpec {
JobSpec {
id: uuid::Uuid::new_v4(),
name: "t".into(),
cwd: "/".into(),
command: vec!["true".into()],
env: Default::default(),
cpu: 1,
mem: 1 << 20,
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,
locks: vec![],
claims: Default::default(),
retries: 0,
nice: None,
needs: vec![],
after: vec![],
dedupe_key: None,
dedupe_window: 0,
submitted_at: 0,
}
}
#[test]
fn a_version_reads_into_three_numbers() {
assert_eq!(parse("0.5.1"), (0, 5, 1));
assert_eq!(parse("1.0.0"), (1, 0, 0));
assert_eq!(parse("0.5"), (0, 5, 0));
assert_eq!(parse("0.0.0-dev"), (0, 0, 0));
assert_eq!(parse("0.0.0-dev+g98513e2"), (0, 0, 0));
assert_eq!(parse("0.0.0-dev+g98513e2.dirty"), (0, 0, 0));
assert_eq!(parse("not-a-version"), (0, 0, 0));
assert_eq!(parse(""), (0, 0, 0));
}
fn below(version: &str, pid: i32) -> String {
match check_floor(version, pid) {
Floor::Below(message) => message,
Floor::Development(m) => panic!("`{version}` gave a warning and not a refusal: {m}"),
Floor::Supported => panic!("`{version}` passed the floor"),
}
}
fn supported(version: &str) -> bool {
matches!(check_floor(version, 1), Floor::Supported)
}
#[test]
fn a_coordinator_below_the_floor_is_refused() {
let err = below("0.5.2", 4321);
assert!(
err.contains("0.6.0"),
"the message must name the floor: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
assert!(supported("0.6.0"), "the floor itself is supported");
assert!(supported("0.7.3"));
assert!(supported("1.0.0"));
below("", 1);
below("not-a-version", 1);
}
#[test]
fn a_development_build_is_warned_about_and_not_refused() {
let warning = match check_floor("0.0.0-dev", 4321) {
Floor::Development(message) => message,
Floor::Below(m) => panic!("a development build must not be refused: {m}"),
Floor::Supported => panic!("a development build below the floor must be named"),
};
assert!(
warning.contains("development build"),
"the message must say what happened: {warning}"
);
assert!(
warning.contains("no promise"),
"the message must say why it matters: {warning}"
);
assert!(
warning.contains("kill 4321") && warning.contains("qex info"),
"the message must give the remedy: {warning}"
);
for form in [
"0.0.0-dev",
"0.0.0-dev+g98513e2",
"0.0.0-dev+g98513e2.dirty",
"0.0.0-dev+unknown",
] {
assert!(
matches!(check_floor(form, 1), Floor::Development(_)),
"`{form}` must give a warning"
);
}
assert!(supported("0.7.3"));
}
#[test]
fn the_floor_of_the_build_agrees_with_the_floor_of_the_code() {
let written = env!("QEX_BUILD_FLOOR");
let (major, minor, patch) = CAPABILITY_FLOOR;
assert_eq!(
written,
format!("{major}.{minor}.{patch}"),
"build.rs holds {written} and capabilities.rs holds {major}.{minor}.{patch}"
);
assert_eq!(parse(written), CAPABILITY_FLOOR);
}
#[test]
fn this_build_is_not_below_the_floor() {
let mine = crate::version::VERSION;
assert!(
parse(mine) >= CAPABILITY_FLOOR || crate::version::is_development(mine),
"this build reports `{mine}`, which is below the capability floor and is not a \
development build"
);
assert!(
!matches!(check_floor(mine, 1), Floor::Below(_)),
"this build reports `{mine}`, which its own CLI would refuse"
);
}
#[test]
fn a_job_that_needs_nothing_passes_every_coordinator() {
assert!(required_by(&spec()).is_empty());
assert!(check(&[], "0.1.0", 1, &spec()).is_ok());
}
#[test]
fn nice_is_refused_by_a_coordinator_that_cannot_obey_it() {
let mut s = spec();
s.nice = Some(19);
let old: Vec<String> = ALL
.iter()
.filter(|c| **c != "politeness")
.map(|c| c.to_string())
.collect();
let err = check(&old, "0.6.0", 4321, &s).unwrap_err();
assert!(
err.contains("--nice"),
"the message must name the option: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
let now: Vec<String> = ALL.iter().map(|c| c.to_string()).collect();
assert!(check(&now, env!("CARGO_PKG_VERSION"), 1, &s).is_ok());
assert!(required_by(&spec()).is_empty());
}
#[test]
fn a_lock_is_refused_by_a_coordinator_that_has_no_locks() {
let mut s = spec();
s.locks = vec!["target".into()];
let old: Vec<String> = ALL
.iter()
.filter(|c| **c != "locks")
.map(|c| c.to_string())
.collect();
let err = check(&old, "0.6.0", 4321, &s).unwrap_err();
assert!(
err.contains("--lock"),
"the message must name the option: {err}"
);
assert!(
err.contains("in silence"),
"the message must give the danger: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
let new: Vec<String> = ALL.iter().map(|c| c.to_string()).collect();
assert!(check(&new, "0.6.0", 4321, &s).is_ok());
}
#[test]
fn a_dedupe_key_is_refused_by_a_coordinator_that_has_no_dedupe() {
let mut s = spec();
s.dedupe_key = Some("build:/x".into());
assert_eq!(required_by(&s), vec!["dedupe"]);
let old: Vec<String> = ALL
.iter()
.filter(|c| **c != "dedupe")
.map(|c| c.to_string())
.collect();
let err = check(&old, "0.7.1", 4321, &s).unwrap_err();
assert!(
err.contains("--dedupe-key"),
"the message must name the option: {err}"
);
assert!(
err.contains("in silence"),
"the message must give the danger: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
let new: Vec<String> = ALL.iter().map(|c| c.to_string()).collect();
assert!(check(&new, "0.8.0", 4321, &s).is_ok());
}
#[test]
fn a_lock_does_not_need_the_pools_capability() {
let mut s = spec();
s.locks = vec!["target".into()];
assert_eq!(required_by(&s), vec!["locks"]);
let old: Vec<String> = ALL
.iter()
.filter(|c| **c != "pools")
.map(|c| c.to_string())
.collect();
assert!(
check(&old, "0.7.1", 4321, &s).is_ok(),
"a coordinator with `locks` and no `pools` must accept a lock"
);
}
#[test]
fn a_claim_is_refused_by_a_coordinator_that_has_no_pools() {
let mut s = spec();
s.claims.insert(
"gpu".into(),
crate::spec::PoolClaim {
count: 1,
size: None,
},
);
assert_eq!(required_by(&s), vec!["pools"]);
let old: Vec<String> = ALL
.iter()
.filter(|c| **c != "pools")
.map(|c| c.to_string())
.collect();
let err = check(&old, "0.7.1", 4321, &s).unwrap_err();
assert!(
err.contains("--gpu"),
"the message must name the option: {err}"
);
assert!(
err.contains("in silence"),
"the message must give the danger: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
let new: Vec<String> = ALL.iter().map(|c| c.to_string()).collect();
assert!(check(&new, "0.8.0", 4321, &s).is_ok());
}
#[test]
fn a_queue_limit_is_refused_by_a_coordinator_that_cannot_obey_it() {
let mut s = spec();
s.max_queue_time = Some(1800);
let old: Vec<String> = ALL
.iter()
.filter(|c| **c != "max-queue-time")
.map(|c| c.to_string())
.collect();
let err = check(&old, "0.7.1", 4321, &s).unwrap_err();
assert!(
err.contains("--max-queue-time"),
"the message must name the option: {err}"
);
assert!(
err.contains("in silence"),
"the message must give the danger: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
let new: Vec<String> = ALL.iter().map(|c| c.to_string()).collect();
assert!(check(&new, "0.8.0", 4321, &s).is_ok());
}
#[test]
fn a_command_is_refused_by_a_coordinator_that_does_not_know_it() {
let old: Vec<String> = ALL
.iter()
.filter(|c| **c != "events")
.map(|c| c.to_string())
.collect();
let err =
check_command(&old, "0.7.1", 4321, "events", "qex events", "no reason").unwrap_err();
assert!(
err.contains("qex events"),
"the message must name the command: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
let new: Vec<String> = ALL.iter().map(|c| c.to_string()).collect();
assert!(check_command(&new, "0.8.0", 4321, "events", "qex events", "no reason").is_ok());
}
#[test]
fn a_pause_is_refused_by_a_coordinator_that_cannot_pause() {
let old: Vec<String> = ALL
.iter()
.filter(|c| **c != "pause")
.map(|c| c.to_string())
.collect();
let err = check_command(
&old,
"0.7.1",
3507877,
"pause",
"qex pause queue",
"The coordinator would start the jobs of the queue, and you would believe that the \
machine is quiet.",
)
.unwrap_err();
assert!(
err.contains("qex pause queue"),
"the message must name the command: {err}"
);
assert!(
err.contains("kill 3507877"),
"the message must give the remedy: {err}"
);
let new: Vec<String> = ALL.iter().map(|c| c.to_string()).collect();
assert!(check_command(&new, "0.8.0", 1, "pause", "qex pause queue", "no reason").is_ok());
let resume = check_command(
&old,
"0.7.1",
3507877,
"pause",
"qex resume queue",
"That coordinator does not read the pause record, so it already starts the jobs of \
the queue. This command would change nothing.",
)
.unwrap_err();
assert!(
!resume.contains("you would believe that the machine is quiet"),
"the resume message must not give the danger of a pause: {resume}"
);
}
#[test]
fn this_build_says_that_it_can_pause() {
assert!(ALL.contains(&"pause"));
}
#[test]
fn this_build_says_that_it_has_the_event_stream() {
assert!(ALL.contains(&"events"));
}
#[test]
fn each_option_that_needs_the_coordinator_is_tested() {
let mut s = spec();
s.retries = 2;
assert_eq!(required_by(&s), vec!["retries"]);
let mut s = spec();
s.needs = vec![uuid::Uuid::new_v4()];
assert_eq!(required_by(&s), vec!["dependencies"]);
let mut s = spec();
s.group = Some(uuid::Uuid::new_v4());
assert_eq!(required_by(&s), vec!["groups"]);
let mut s = spec();
s.max_queue_time = Some(60);
assert_eq!(required_by(&s), vec!["max-queue-time"]);
let mut s = spec();
s.locks = vec!["a".into()];
s.retries = 1;
let err = check(&[], "0.1.0", 7, &s).unwrap_err();
assert!(
err.contains("--lock") && err.contains("--retries"),
"got: {err}"
);
}
fn without(name: &str) -> Vec<String> {
ALL.iter()
.filter(|c| **c != name)
.map(|c| c.to_string())
.collect()
}
fn stage(name: &str, needs: &[&'static str]) -> StageNeeds {
StageNeeds {
stage: name.to_string(),
needs: needs.to_vec(),
}
}
#[test]
fn an_option_on_a_later_stage_is_refused() {
let stages = [stage("build", &[]), stage("test", &["politeness"])];
let err = check_pipeline(
&without("politeness"),
"0.6.0",
4321,
&[
("dependencies", Source::EveryPipeline),
("groups", Source::EveryPipeline),
],
&stages,
)
.unwrap_err();
assert!(
err.contains("--nice"),
"the message must name the option: {err}"
);
assert!(
err.contains("`test`"),
"the message must name the stage that the reader corrects: {err}"
);
assert!(
!err.contains("`build`"),
"the message must not name a stage that asks for nothing: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
}
#[test]
fn each_capability_of_a_later_stage_is_refused() {
for (capability, option) in [
("locks", "--lock"),
("retries", "--retries"),
("politeness", "--nice"),
("max-queue-time", "--max-queue-time"),
("pools", "--gpu"),
] {
let stages = [stage("one", &[]), stage("two", &[capability])];
let err = check_pipeline(&without(capability), "0.6.0", 1, &[], &stages)
.expect_err(&format!("{capability} on a later stage must be refused"));
assert!(
err.contains(option) && err.contains("`two`"),
"the message must name {option} and the stage: {err}"
);
}
}
#[test]
fn one_message_names_every_stage_with_the_same_fault() {
let stages = [
stage("build", &["politeness"]),
stage("test", &["politeness"]),
stage("ship", &["locks"]),
];
let err = check_pipeline(&without("politeness"), "0.6.0", 1, &[], &stages).unwrap_err();
assert_eq!(
err.matches("--nice").count(),
1,
"one option gives one line: {err}"
);
assert!(
err.contains("`build`") && err.contains("`test`"),
"the line must name every stage that asks for it: {err}"
);
assert!(
!err.contains("--lock"),
"a capability that the coordinator has must not appear: {err}"
);
}
#[test]
fn a_capability_of_every_pipeline_names_no_stage() {
let stages = [stage("only", &[])];
let err = check_pipeline(
&without("groups"),
"0.6.0",
1,
&[("groups", Source::EveryPipeline)],
&stages,
)
.unwrap_err();
assert!(
err.contains("every pipeline asks for it"),
"the message must say that the file itself asks for it: {err}"
);
assert!(
!err.contains("`only`"),
"no stage is responsible for it: {err}"
);
}
#[test]
fn a_pipeline_that_needs_nothing_passes_every_coordinator() {
let now: Vec<String> = ALL.iter().map(|c| c.to_string()).collect();
let stages = [stage("one", &[]), stage("two", &["locks"])];
assert!(check_pipeline(&now, env!("CARGO_PKG_VERSION"), 1, &[], &stages).is_ok());
}
}