use crate::config::{Config, OversizedPolicy};
use crate::daemon::{log, Coordinator};
use crate::job::{self, JobState};
use crate::paths;
use crate::spec::JobSpec;
use crate::sys;
use crate::units::format_size;
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Size {
Fits,
TooBig(String),
}
pub fn size_check(cfg: &Config, cpu: u64, mem: u64) -> Size {
let cpu_budget = cfg.budget_cpu().unwrap_or(1);
let mem_budget = cfg.budget_mem().unwrap_or(0);
let mut reasons = Vec::new();
if cpu > cpu_budget {
reasons.push(format!(
"the job claims {cpu} cores and the budget is {cpu_budget} cores"
));
}
if mem > mem_budget {
reasons.push(format!(
"the job claims {} of memory and the budget is {}",
format_size(mem),
format_size(mem_budget)
));
}
if reasons.is_empty() {
Size::Fits
} else {
Size::TooBig(reasons.join("; "))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Blocker {
Sibling,
Peer { count: usize },
Machine,
OversizedWaitsForIdle,
OversizedParked,
}
impl Blocker {
fn may_reserve(&self) -> bool {
matches!(self, Blocker::Sibling | Blocker::OversizedWaitsForIdle)
}
pub fn word(&self) -> &'static str {
match self {
Blocker::Sibling => "waits-for-capacity",
Blocker::Peer { .. } => "waits-for-peer",
Blocker::Machine => "waits-for-machine",
Blocker::OversizedWaitsForIdle => "waits-for-idle",
Blocker::OversizedParked => "parked",
}
}
}
enum Admit {
Yes,
No {
blocker: Blocker,
reason: String,
held_reason: Option<String>,
},
}
struct Machine {
available: u64,
pressure: Option<f64>,
peers: crate::peers::Claims,
}
impl Machine {
fn read(cfg: &Config) -> Self {
Self {
available: sys::available_memory(),
pressure: sys::memory_pressure(),
peers: crate::peers::claims(cfg),
}
}
}
fn cores(n: u64) -> String {
if n == 1 {
"1 core".to_string()
} else {
format!("{n} cores")
}
}
fn other_users(n: usize) -> String {
if n == 1 {
"1 other user holds".to_string()
} else {
format!("{n} other users hold")
}
}
fn sibling_wait(fact: String, resource: &str) -> Admit {
Admit::No {
blocker: Blocker::Sibling,
reason: format!(
"{fact} Those jobs release the {resource} when they stop. qex can start a smaller job \
before this one."
),
held_reason: Some(format!(
"{fact} qex starts no other job before this one. Read `qex list` to see the jobs that \
hold the {resource}."
)),
}
}
fn lock_conflict(state: &crate::daemon::State, spec: &JobSpec) -> Option<String> {
if spec.locks.is_empty() {
return None;
}
for name in &spec.locks {
if state.paused.locks.contains_key(name) {
return Some(crate::pause::lock_reason(name));
}
}
for job in state.jobs.values() {
if !job.status.state.is_active() {
continue;
}
for name in &spec.locks {
if job.spec.locks.contains(name) {
return Some(format!(
"waits for the lock `{name}`, which the job {} ({}) holds",
&job.status.id.to_string()[..8],
job.status.display_name()
));
}
}
}
None
}
fn admit(
cfg: &Config,
cpu: u64,
mem: u64,
cpu_used: u64,
mem_used: u64,
machine: &Machine,
) -> Admit {
let cpu_budget = cfg.budget_cpu().unwrap_or(1);
let mem_budget = cfg.budget_mem().unwrap_or(0);
if cpu_used + cpu > cpu_budget {
return sibling_wait(
format!(
"waits for cores: this job needs {}, and the jobs of this queue hold {} of the {} \
in the budget.",
cores(cpu),
cpu_used,
cores(cpu_budget)
),
"cores",
);
}
if mem_used + mem > mem_budget {
return sibling_wait(
format!(
"waits for memory: this job needs {}, and the jobs of this queue hold {} of the {} \
budget.",
format_size(mem),
format_size(mem_used),
format_size(mem_budget)
),
"memory",
);
}
let peers = &machine.peers;
if peers.cpu > 0 || peers.mem > 0 {
if cpu_used + peers.cpu + cpu > cpu_budget {
return Admit::No {
blocker: Blocker::Peer { count: peers.count },
reason: format!(
"this job cannot fit while another user holds capacity: the job needs {}, this \
queue holds {} of the {} in the budget, and {} {}. qex does not control that \
user, so this wait has no known end. qex starts the jobs behind this one \
while the capacity is not free. Read `qex info` for the load of the machine.",
cores(cpu),
cpu_used,
cores(cpu_budget),
other_users(peers.count),
cores(peers.cpu)
),
held_reason: None,
};
}
if mem_used + peers.mem + mem > mem_budget {
return Admit::No {
blocker: Blocker::Peer { count: peers.count },
reason: format!(
"this job cannot fit while another user holds capacity: the job needs {}, this \
queue holds {} of the {} budget, and {} {}. qex does not control that user, \
so this wait has no known end. qex starts the jobs behind this one while the \
capacity is not free. Read `qex info` for the load of the machine.",
format_size(mem),
format_size(mem_used),
format_size(mem_budget),
other_users(peers.count),
format_size(peers.mem)
),
held_reason: None,
};
}
}
let reserve = cfg.reserve_mem().unwrap_or(0);
let available = machine.available;
if available < reserve + mem {
let mut reason = format!(
"waits for memory: the machine reports {} that a new program can use, and the job \
needs {} with {} in reserve",
format_size(available),
format_size(mem),
format_size(reserve)
);
match machine.pressure {
Some(p) if p < 1.0 => reason.push_str(&format!(
". The memory pressure is {p:.1}, so the machine is NOT short of memory now: \
this number counts the memory that a program can use with no operation to the \
disk, and it does not count the memory that the kernel parked in swap. Give a \
smaller claim, or lower `reserve_mem` in the configuration, if this job waits \
and the machine is healthy"
)),
_ => reason.push_str(
". Use `qex info` to see the load of the machine, and `qex list` to see what \
holds the memory",
),
}
reason.push_str(
". qex does not control the programs outside this queue, so this wait has no known \
end. qex starts the jobs behind this one while the memory is not free.",
);
return Admit::No {
blocker: Blocker::Machine,
reason,
held_reason: None,
};
}
if let Some(pressure) = machine.pressure {
if pressure > cfg.system.max_pressure {
return Admit::No {
blocker: Blocker::Machine,
reason: format!(
"waits for the machine: the memory pressure is {:.1} and the limit is {:.1}. \
qex does not control the programs outside this queue, so this wait has no \
known end. qex starts the jobs behind this one while the pressure is high.",
pressure, cfg.system.max_pressure
),
held_reason: None,
};
}
}
Admit::Yes
}
enum Verdict {
Start,
Wait {
blocker: Blocker,
reason: String,
held_reason: Option<String>,
},
}
fn verdict(
cfg: &Config,
cpu: u64,
mem: u64,
cpu_used: u64,
mem_used: u64,
machine: &Machine,
quiet: bool,
) -> Verdict {
match size_check(cfg, cpu, mem) {
Size::Fits => match admit(cfg, cpu, mem, cpu_used, mem_used, machine) {
Admit::Yes => Verdict::Start,
Admit::No {
blocker,
reason,
held_reason,
} => Verdict::Wait {
blocker,
reason,
held_reason,
},
},
Size::TooBig(reason) => {
if cfg.queue.oversized == OversizedPolicy::RunWhenIdle {
if quiet {
return Verdict::Start;
}
return Verdict::Wait {
blocker: Blocker::OversizedWaitsForIdle,
reason: format!(
"{reason}; qex starts this job when no other job operates. qex starts the \
jobs behind this one until then."
),
held_reason: Some(format!(
"{reason}; qex starts this job when no other job operates. qex starts no \
other job before this one, so the queue becomes empty."
)),
};
}
let text = match cfg.queue.oversized {
OversizedPolicy::Queue => format!(
"{reason}; the config file keeps this job in the queue. This job never starts, \
so qex starts the jobs behind it."
),
_ => reason.clone(),
};
Verdict::Wait {
blocker: Blocker::OversizedParked,
reason: text,
held_reason: None,
}
}
}
}
pub fn run(coord: Arc<Coordinator>) {
loop {
if coord.state.lock().unwrap().stop {
return;
}
let config = crate::config::read_config_file();
crate::daemon::reload_config(&mut coord.state.lock().unwrap(), config);
let changed = {
let mut state = coord.state.lock().unwrap();
let changed = state.refresh_active();
state.publish_changes();
changed
};
match step(&coord) {
Ok((started, finished)) if started > 0 || finished > 0 || changed => coord.notify(),
Ok(_) => {}
Err(e) => log(&format!("the scheduler failed: {e:#}")),
}
coord.state.lock().unwrap().publish_changes();
{
let state = coord.state.lock().unwrap();
let (cpu, mem) = state.claimed();
let cfg = state.cfg.clone();
drop(state);
crate::peers::publish(&cfg, cpu, mem);
}
let state = coord.state.lock().unwrap();
let wait = if state.config_settling.is_some() {
Duration::from_millis(50)
} else {
Duration::from_millis(500)
};
let _ = coord.changed.wait_timeout(state, wait).unwrap();
}
}
fn step(coord: &Arc<Coordinator>) -> anyhow::Result<(usize, usize)> {
let mut started = 0usize;
let mut finished = 0usize;
loop {
let choice = {
let mut state = coord.state.lock().unwrap();
let now = sys::now_secs();
let queue_pause = state.paused.queue.clone();
if state.paused.expire(now) {
if let Some(record) = queue_pause {
if state.paused.queue.is_none() {
crate::pause::end_queue_pause(&mut state, &record, now);
log("a pause reached its end; qex starts the queue again");
}
}
state.save_pause();
}
let active = state.count_state(|s| s.is_active());
if active == 0 && state.idle_since.is_none() {
state.idle_since = Some(Instant::now());
} else if active > 0 {
state.idle_since = None;
}
choose(&mut state)
};
finished += choice.finished;
match choice.start {
Some(id) => {
start_job(coord, id)?;
started += 1;
}
None => break,
}
}
Ok((started, finished))
}
enum Depends {
Ready,
Waiting(String),
Broken { reason: String, root: uuid::Uuid },
}
fn depends(state: &crate::daemon::State, id: uuid::Uuid) -> Depends {
let Some(job) = state.jobs.get(&id) else {
return Depends::Ready;
};
for dep in &job.spec.needs {
let Some(other) = state.jobs.get(dep) else {
continue;
};
if !other.status.state.is_terminal() {
return Depends::Waiting(format!(
"waits for the job {} ({}), which is {}",
&dep.to_string()[..8],
other.status.display_name(),
other.status.state
));
}
if other.status.state != JobState::Completed {
let root = other.status.caused_by.unwrap_or(*dep);
let root_name = state
.jobs
.get(&root)
.map(|j| j.status.display_name())
.unwrap_or_else(|| "unknown".to_string());
let root_state = state
.jobs
.get(&root)
.map(|j| j.status.state.to_string())
.unwrap_or_else(|| other.status.state.to_string());
let never_ran = matches!(root_state.as_str(), "cancelled" | "expired");
let advice = if never_ran {
String::new()
} else {
format!(" Read `qex logs {}` for the cause.", &root.to_string()[..8])
};
return Depends::Broken {
reason: format!(
"the job {} ({}) is {}, so this job did not run.{advice}",
&root.to_string()[..8],
root_name,
root_state
),
root,
};
}
}
for dep in &job.spec.after {
let Some(other) = state.jobs.get(dep) else {
continue;
};
if !other.status.state.is_terminal() {
return Depends::Waiting(format!(
"waits for the job {} ({}) to stop, whatever its result",
&dep.to_string()[..8],
other.status.display_name()
));
}
}
Depends::Ready
}
fn skip(state: &mut crate::daemon::State, id: uuid::Uuid, reason: String, root: uuid::Uuid) {
let Some(job) = state.jobs.get_mut(&id) else {
return;
};
job.status.state = JobState::Skipped;
job.status.finished_at = Some(sys::now_secs());
job.status.blocked_reason = None;
job.status.error = Some(reason);
job.status.caused_by = Some(root);
let status = job.status.clone();
state.queue.retain(|q| *q != id);
if let Ok(dir) = paths::job_dir(&id) {
job::write_status(&dir, &status).ok();
crate::hook::fire_detached(&dir, &status);
}
state.publish_changes();
log(&format!(
"job {id} did not run, because a job that it needed did not succeed"
));
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Waited {
Capacity,
AJob,
ALock,
}
fn expire(
state: &mut crate::daemon::State,
id: uuid::Uuid,
waited: u64,
last_reason: &str,
cause: Waited,
) {
let Some(job) = state.jobs.get_mut(&id) else {
return;
};
if job.status.state != JobState::Queued {
return;
}
if job.status.started_at.is_some() {
return;
}
let limit = job.spec.max_queue_time.unwrap_or(0);
job.status.state = JobState::Expired;
job.status.finished_at = Some(sys::now_secs());
job.status.blocked_reason = None;
let remedy = match cause {
Waited::AJob => {
"The job waited for a job that it needs, and not for capacity. Give a \
--max-queue-time that covers the whole pipeline, or give no value on a stage \
that waits for an earlier stage."
}
Waited::ALock => {
"The job waited for a lock, and not for capacity. qex gives a lock to one job \
at a time, whatever the machine has free. Give a --max-queue-time that covers \
the longest job that takes the same lock, or give the two jobs different lock \
names if they can operate together."
}
Waited::Capacity => {
"Give the job a smaller claim, wait until the machine is quiet, or give a longer \
--max-queue-time, then submit the job again."
}
};
job.status.error = Some(format!(
"the job did not start. It waited {} in the queue, and its --max-queue-time is {}. \
The last reason was: {last_reason}. {remedy}",
crate::units::format_duration(Duration::from_secs(waited)),
crate::units::format_duration(Duration::from_secs(limit)),
));
let status = job.status.clone();
state.queue.retain(|q| *q != id);
if let Ok(dir) = paths::job_dir(&id) {
job::write_status(&dir, &status).ok();
crate::hook::fire_detached(&dir, &status);
}
log(&format!(
"job {id} did not start; it waited {waited}s and its queue limit is {limit}s"
));
}
fn overdue(state: &crate::daemon::State, chosen: Option<uuid::Uuid>) -> Vec<(uuid::Uuid, u64)> {
if state.paused.queue.is_some() {
return Vec::new();
}
let now = sys::now_secs();
state
.queue
.iter()
.copied()
.filter(|id| Some(*id) != chosen)
.filter_map(|id| {
let job = state.jobs.get(&id)?;
if job.status.state != JobState::Queued {
return None;
}
let limit = job.spec.max_queue_time?;
let waited = now
.saturating_sub(job.status.submitted_at)
.saturating_sub(job.status.queue_pause_secs);
(waited >= limit).then_some((id, waited))
})
.collect()
}
struct Choice {
start: Option<uuid::Uuid>,
finished: usize,
}
struct Head {
id: uuid::Uuid,
name: String,
mem: u64,
blocker: Blocker,
reserved: bool,
passed_by: u32,
}
fn choose(state: &mut crate::daemon::State) -> Choice {
let (cpu_used, mem_used) = state.claimed();
let cfg = state.cfg.clone();
let active = state.count_state(|s| s.is_active());
let idle_since = state.idle_since;
let paused = state.paused.queue.clone();
let max_bypass = cfg.queue.max_bypass;
let settle = cfg.settle().unwrap_or(Duration::from_secs(3));
let quiet = active == 0 && idle_since.map(|t| t.elapsed() >= settle).unwrap_or(false);
let machine = Machine::read(&cfg);
let mut chosen = None;
let mut reasons: Vec<(uuid::Uuid, Option<String>)> = Vec::new();
let mut to_skip: Vec<(uuid::Uuid, String, uuid::Uuid)> = Vec::new();
let mut waits_for: std::collections::BTreeMap<uuid::Uuid, Waited> = Default::default();
let mut ready: Vec<uuid::Uuid> = Vec::new();
for id in state.queue.iter().copied() {
let Some(job) = state.jobs.get(&id) else {
continue;
};
if job.status.state != JobState::Queued {
continue;
}
match depends(state, id) {
Depends::Ready => match lock_conflict(state, &job.spec) {
Some(reason) => {
waits_for.insert(id, Waited::ALock);
reasons.push((id, Some(reason)));
}
None => ready.push(id),
},
Depends::Waiting(reason) => {
waits_for.insert(id, Waited::AJob);
reasons.push((id, Some(reason)));
}
Depends::Broken { reason, root } => to_skip.push((id, reason, root)),
}
}
if let Some(record) = &paused {
let reason = crate::pause::queue_reason(record);
for id in ready.iter().copied() {
reasons.push((id, Some(reason.clone())));
}
ready.clear();
}
let mut head: Option<Head> = None;
let mut started_now: Option<uuid::Uuid> = None;
for id in ready.iter().copied() {
let Some(job) = state.jobs.get(&id) else {
continue;
};
let (claim_cpu, claim_mem) = (job.status.cpu, job.status.mem);
let name = job.status.display_name();
let passed_by = job.status.passed_by;
match verdict(
&cfg, claim_cpu, claim_mem, cpu_used, mem_used, &machine, quiet,
) {
Verdict::Start => {
chosen = Some(id);
started_now = Some(id);
break;
}
Verdict::Wait {
blocker,
reason,
held_reason,
} => {
if head.is_some() {
reasons.push((id, Some(reason)));
continue;
}
let reserved = blocker.may_reserve() && passed_by >= max_bypass;
reasons.push((
id,
Some(if reserved {
held_reason.unwrap_or(reason)
} else {
reason
}),
));
head = Some(Head {
id,
name,
mem: claim_mem,
blocker,
reserved,
passed_by,
});
if reserved {
break;
}
}
}
}
if let Some(h) = &head {
if h.reserved {
for later in ready.iter().copied() {
if later == h.id {
continue;
}
reasons.push((
later,
Some(format!(
"waits for the job {} ({}), which is at the front of the queue and needs \
{}. qex keeps the capacity for that job, because {} job(s) already \
started before it.",
&h.id.to_string()[..8],
h.name,
format_size(h.mem),
h.passed_by
)),
));
}
}
}
let mut finished = to_skip.len();
for (id, reason, root) in to_skip {
skip(state, id, reason, root);
}
let mut dirty: std::collections::BTreeSet<uuid::Uuid> = Default::default();
for (id, reason) in reasons {
if let Some(job) = state.jobs.get_mut(&id) {
if job.status.state != JobState::Queued {
continue;
}
if job.status.blocked_reason != reason {
job.status.blocked_reason = reason;
dirty.insert(id);
}
}
}
if let Some(h) = &head {
if let Some(job) = state.jobs.get_mut(&h.id) {
if job.status.blocked_since.is_none() {
job.status.blocked_since = Some(sys::now_secs());
dirty.insert(h.id);
}
if started_now.is_some() {
job.status.passed_by = job.status.passed_by.saturating_add(1);
dirty.insert(h.id);
}
}
}
for id in dirty {
let Some(job) = state.jobs.get(&id) else {
continue;
};
let status = job.status.clone();
if let Ok(dir) = paths::job_dir(&id) {
job::write_status(&dir, &status).ok();
}
}
state.head = head.map(|h| crate::daemon::HeadInfo {
id: h.id,
name: h.name,
blocker: h.blocker.word().to_string(),
reserved: h.reserved,
passed_by: h.passed_by,
});
state.peer_claims = machine.peers;
for (id, waited) in overdue(state, chosen) {
let reason = state
.jobs
.get(&id)
.and_then(|j| j.status.blocked_reason.clone())
.unwrap_or_else(|| "the job waited for free capacity".to_string());
let cause = waits_for.get(&id).copied().unwrap_or(Waited::Capacity);
expire(state, id, waited, &reason, cause);
finished += 1;
}
state.publish_changes();
Choice {
start: chosen,
finished,
}
}
fn start_job(coord: &Arc<Coordinator>, id: uuid::Uuid) -> anyhow::Result<()> {
let (forced_reason, name, status) = {
let mut state = coord.state.lock().unwrap();
let Some(job) = state.jobs.get(&id) else {
return Ok(());
};
if job.status.state != JobState::Queued {
return Ok(());
}
if state.paused.queue.is_some() {
log(&format!("job {id} does not start: the queue is paused"));
return Ok(());
}
if let Some(name) = job
.spec
.locks
.iter()
.find(|n| state.paused.locks.contains_key(*n))
{
log(&format!(
"job {id} does not start: a person holds the lock `{name}`"
));
return Ok(());
}
let forced = match size_check(&state.cfg, job.status.cpu, job.status.mem) {
Size::TooBig(reason) => Some(format!(
"{reason}. qex started this job alone because no other job operated."
)),
Size::Fits => None,
};
let Some(job) = state.jobs.get_mut(&id) else {
return Ok(());
};
job.status.state = JobState::Starting;
job.status.started_at = Some(sys::now_secs());
job.status.blocked_reason = None;
job.status.blocked_since = None;
job.status.passed_by = 0;
job.status.forced = forced.is_some();
job.status.forced_reason = forced.clone();
let status = job.status.clone();
let name = crate::job::safe_name(&job.spec.name);
state.queue.retain(|q| *q != id);
state.last_start_at = Some(sys::now_secs());
state.publish_changes();
(forced, name, status)
};
if let Err(e) = write_started(&id, &status) {
let mut state = coord.state.lock().unwrap();
if let Some(job) = state.jobs.get_mut(&id) {
job.status.state = JobState::Failed;
job.status.finished_at = Some(sys::now_secs());
job.status.error = Some(format!("qex could not write the job record: {e:#}"));
}
state.publish_changes();
drop(state);
coord.notify();
log(&format!("job {id} could not start: {e:#}"));
return Ok(());
}
if let Some(reason) = &forced_reason {
log(&format!(
"job {id} ({name}) starts although it is too large: {reason}"
));
}
match crate::supervisor::spawn(id) {
Ok(pid) => {
{
let mut state = coord.state.lock().unwrap();
if let Some(job) = state.jobs.get_mut(&id) {
job.supervisor_pid = Some(pid);
job.status.supervisor_pid = Some(pid);
}
}
crate::supervisor::record_supervisor_pid(&id, pid);
log(&format!(
"job {id} ({name}) started; the supervisor pid is {pid}"
));
let coord = Arc::clone(coord);
std::thread::spawn(move || crate::supervisor::reap(coord, id, pid));
Ok(())
}
Err(e) => {
let mut state = coord.state.lock().unwrap();
if let Some(job) = state.jobs.get_mut(&id) {
job.status.state = JobState::Failed;
job.status.finished_at = Some(sys::now_secs());
job.status.error = Some(format!("qex could not start the job: {e:#}"));
let status = job.status.clone();
state.publish_changes();
drop(state);
if let Ok(dir) = paths::job_dir(&id) {
job::write_status(&dir, &status).ok();
crate::hook::fire_detached(&dir, &status);
}
}
coord.notify();
log(&format!("job {id} could not start: {e:#}"));
Ok(())
}
}
}
fn write_started(id: &uuid::Uuid, status: &crate::job::JobStatus) -> anyhow::Result<()> {
let dir = paths::job_dir(id)?;
job::write_status(&dir, status)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with(cpu: &str, mem: &str) -> Config {
toml::from_str(&format!(
"[budget]\ncpu = \"{cpu}\"\nmem = \"{mem}\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[peers]\nenabled = false\n"
))
.unwrap()
}
fn machine_for(cfg: &Config) -> Machine {
Machine::read(cfg)
}
fn wait_reason(a: Admit, what: &str) -> String {
match a {
Admit::Yes => panic!("{what}"),
Admit::No { reason, .. } => reason,
}
}
fn spec_with(cpu: u64, mem: u64) -> JobSpec {
JobSpec {
id: uuid::Uuid::new_v4(),
name: "t".into(),
cwd: "/".into(),
command: vec!["true".into()],
env: Default::default(),
cpu,
mem,
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![],
retries: 0,
nice: None,
needs: vec![],
after: vec![],
submitted_at: 0,
dedupe_key: None,
dedupe_window: 0,
}
}
#[test]
fn a_job_inside_the_budget_fits() {
let cfg = cfg_with("4", "8GB");
assert_eq!(size_check(&cfg, 4, 8 << 30), Size::Fits);
assert_eq!(size_check(&cfg, 1, 1 << 30), Size::Fits);
}
#[test]
fn a_job_larger_than_the_budget_is_too_big() {
let cfg = cfg_with("4", "8GB");
let Size::TooBig(reason) = size_check(&cfg, 64, 1 << 30) else {
panic!("a job of 64 cores must not fit a budget of 4 cores");
};
assert!(
reason.contains("cores"),
"the reason must name the cores: {reason}"
);
let Size::TooBig(reason) = size_check(&cfg, 1, 64 << 30) else {
panic!("a job of 64GB must not fit a budget of 8GB");
};
assert!(
reason.contains("memory"),
"the reason must name the memory: {reason}"
);
let Size::TooBig(reason) = size_check(&cfg, 64, 64 << 30) else {
panic!("this job must not fit");
};
assert!(
reason.contains("cores") && reason.contains("memory"),
"got: {reason}"
);
}
#[test]
fn the_budget_limits_the_jobs_that_operate_together() {
let cfg = cfg_with("4", "256MB");
let m = machine_for(&cfg);
let (cpu, mem) = (2, 64 << 20);
assert!(matches!(admit(&cfg, cpu, mem, 2, 64 << 20, &m), Admit::Yes));
let reason = wait_reason(
admit(&cfg, cpu, mem, 4, 64 << 20, &m),
"a job must not start when the cores are in use",
);
assert!(reason.contains("cores"), "got: {reason}");
let reason = wait_reason(
admit(&cfg, cpu, mem, 0, 224 << 20, &m),
"a job must not start when the memory is in use",
);
assert!(reason.contains("memory"), "got: {reason}");
}
#[test]
fn a_job_that_fills_the_budget_exactly_starts() {
let cfg = cfg_with("4", "256MB");
let m = machine_for(&cfg);
assert!(matches!(admit(&cfg, 4, 256 << 20, 0, 0, &m), Admit::Yes));
assert_eq!(size_check(&cfg, 4, 256 << 20), Size::Fits);
}
#[test]
fn the_reserve_stops_a_job_when_the_machine_is_full() {
let mut cfg = cfg_with("4", "8GB");
cfg.system.reserve_mem = "1000GB".into();
let m = machine_for(&cfg);
let reason = wait_reason(
admit(&cfg, 1, 1 << 20, 0, 0, &m),
"the reserve must stop this job",
);
assert!(reason.contains("reserve"), "got: {reason}");
}
#[test]
fn a_paused_queue_expires_no_job() {
let mut state = state_with(JobState::Queued, Some(60), 61);
assert_eq!(
overdue(&state, None).len(),
1,
"the job must be over its limit before the pause, or this test \
measures nothing"
);
state.paused.queue = Some(crate::pause::PauseRecord::new(1, None, None));
assert!(
overdue(&state, None).is_empty(),
"a paused queue must expire no job"
);
}
#[test]
fn the_time_of_a_pause_does_not_count_against_the_limit() {
let mut state = state_with(JobState::Queued, Some(60), 61);
let id = state.queue[0];
state.jobs.get_mut(&id).unwrap().status.queue_pause_secs = 40;
assert!(
overdue(&state, None).is_empty(),
"21 seconds of a limit of 60 must not expire the job"
);
state.jobs.get_mut(&id).unwrap().status.queue_pause_secs = 1;
assert_eq!(
overdue(&state, None).len(),
1,
"60 seconds of a limit of 60 must still expire the job"
);
}
#[test]
fn the_end_of_a_pause_gives_the_time_back_to_each_job_that_waited() {
let now = sys::now_secs();
let mut state = state_with(JobState::Queued, Some(60), 100);
let waiter = state.queue[0];
let late = add_job(&mut state, JobState::Queued, 1, Some(60), 20);
crate::pause::credit_paused_wait(&mut state, now.saturating_sub(30), now);
assert_eq!(
state.jobs[&waiter].status.queue_pause_secs, 30,
"a job that waited through the whole pause takes the whole pause"
);
assert_eq!(
state.jobs[&late].status.queue_pause_secs, 20,
"a job submitted during the pause takes the part after its \
submission only"
);
}
#[test]
fn a_pause_gives_no_time_back_to_a_job_that_operates() {
let now = sys::now_secs();
let mut state = state_with(JobState::Running, Some(60), 100);
let running = state.queue[0];
crate::pause::credit_paused_wait(&mut state, now.saturating_sub(30), now);
assert_eq!(
state.jobs[&running].status.queue_pause_secs, 0,
"a job that operates does not wait in the queue"
);
}
#[test]
fn the_end_of_a_pause_starts_the_settle_timer_again() {
let now = sys::now_secs();
let mut state = state_with(JobState::Queued, Some(60), 100);
let record = crate::pause::PauseRecord::new(1, None, None);
state.idle_since = Some(Instant::now() - Duration::from_secs(3600));
crate::pause::end_queue_pause(&mut state, &record, now);
let waited = state.idle_since.expect("the timer must exist").elapsed();
assert!(
waited < Duration::from_secs(5),
"the end of a pause must start the settle timer again; it says {waited:?}"
);
}
#[test]
fn a_pause_that_reaches_its_time_gives_the_time_back_too() {
let now = sys::now_secs();
let mut state = state_with(JobState::Queued, Some(60), 100);
let id = state.queue[0];
let mut record = crate::pause::PauseRecord::new(1, None, Some(now));
record.paused_at = now.saturating_sub(30);
state.paused.queue = Some(record.clone());
assert!(
state.paused.expire(now),
"a pause with a time in the past must end"
);
assert!(state.paused.queue.is_none());
crate::pause::end_queue_pause(&mut state, &record, now);
assert_eq!(
state.jobs[&id].status.queue_pause_secs, 30,
"a pause that ended by itself must give its time back"
);
}
fn state_with(
job_state: JobState,
max_queue_time: Option<u64>,
waited: u64,
) -> crate::daemon::State {
let mut spec = spec_with(1, 1 << 20);
spec.max_queue_time = max_queue_time;
let id = spec.id;
let mut status = crate::job::JobStatus::new(&spec);
status.state = job_state;
status.submitted_at = sys::now_secs().saturating_sub(waited);
status.blocked_reason = Some("waits for cores: 4 of 4 are in use".into());
let mut jobs = std::collections::BTreeMap::new();
jobs.insert(
id,
crate::daemon::Job {
spec,
status,
supervisor_pid: None,
},
);
crate::daemon::State {
cfg: cfg_with("4", "8GB"),
jobs,
queue: vec![id],
last_contact: Instant::now(),
idle_since: None,
next_sequence: 1,
started_at: sys::now_secs(),
config_seen: 0,
config_settling: None,
config_error: None,
dedupe: Default::default(),
events: crate::events::EventLog::new(),
paused: crate::pause::Paused::default(),
last_start_at: None,
head: None,
peer_claims: Default::default(),
stop: false,
}
}
fn add_job(
state: &mut crate::daemon::State,
job_state: JobState,
cpu: u64,
max_queue_time: Option<u64>,
waited: u64,
) -> uuid::Uuid {
let mut spec = spec_with(cpu, 1 << 20);
spec.max_queue_time = max_queue_time;
let id = spec.id;
let mut status = crate::job::JobStatus::new(&spec);
status.state = job_state;
status.submitted_at = sys::now_secs().saturating_sub(waited);
state.jobs.insert(
id,
crate::daemon::Job {
spec,
status,
supervisor_pid: None,
},
);
if job_state == JobState::Queued {
state.queue.push(id);
}
id
}
#[test]
fn a_small_job_passes_a_job_that_this_queue_holds_back_but_only_twice() {
let mut state = state_with(JobState::Running, None, 0);
state.queue.clear();
state.jobs.clear();
assert_eq!(
state.cfg.queue.max_bypass, 2,
"this test reads the default, so a change to the default must reach it"
);
add_job(&mut state, JobState::Running, 1, None, 0);
let front = add_job(&mut state, JobState::Queued, 4, None, 0);
let behind: Vec<uuid::Uuid> = (0..3)
.map(|_| add_job(&mut state, JobState::Queued, 1, None, 0))
.collect();
assert_eq!(state.jobs[&front].status.state, JobState::Queued);
assert_eq!(state.jobs[&front].status.passed_by, 0);
assert!(state.jobs[&front].status.blocked_since.is_none());
const MARKER: u64 = 1_000_000;
let mut first_since: Option<u64> = None;
for (pass, id) in behind.iter().take(2).enumerate() {
let choice = choose(&mut state);
assert_eq!(
choice.start,
Some(*id),
"pass {}: the next small job must pass the front",
pass + 1
);
assert_eq!(
state.jobs[&front].status.passed_by,
pass as u32 + 1,
"pass {}: the count of the front job must move",
pass + 1
);
let since = state.jobs[&front].status.blocked_since;
match first_since {
None => {
assert!(
since.is_some(),
"the front job must record when it reached the front"
);
first_since = Some(MARKER);
state.jobs.get_mut(&front).unwrap().status.blocked_since = first_since;
}
Some(first) => assert_eq!(
since,
Some(first),
"blocked_since must not move while the job stays at the front"
),
}
state.jobs.get_mut(id).unwrap().status.state = JobState::Running;
state.queue.retain(|q| q != id);
}
let choice = choose(&mut state);
assert_eq!(
choice.start, None,
"the front job must be unpassable at max_bypass, or a stream of \
small jobs starves it"
);
assert_eq!(
state.jobs[&front].status.passed_by, 2,
"the count must stop"
);
assert_eq!(
state.jobs[&front].status.blocked_since, first_since,
"blocked_since must still name the moment that the job reached the front"
);
let head = state.head.as_ref().expect("the pass must record a head");
assert_eq!(head.id, front);
assert!(head.reserved);
assert_eq!(head.blocker, "waits-for-capacity");
let front_reason = state.jobs[&front].status.blocked_reason.clone().unwrap();
assert!(
front_reason.contains("qex starts no other job before this one"),
"the front job must read the text for a job that keeps capacity: {front_reason}"
);
let last = state.jobs[&behind[2]]
.status
.blocked_reason
.clone()
.unwrap();
assert!(
last.contains("qex keeps the capacity for that job"),
"the job behind must read WHY it waits: {last}"
);
}
#[test]
fn the_head_stays_the_first_job_that_cannot_start() {
let mut state = state_with(JobState::Running, None, 0);
state.queue.clear();
state.jobs.clear();
state.cfg.queue.oversized = OversizedPolicy::Queue;
add_job(&mut state, JobState::Running, 1, None, 0);
let parked = add_job(&mut state, JobState::Queued, 64, None, 0);
let squeezed = add_job(&mut state, JobState::Queued, 4, None, 0);
let small = add_job(&mut state, JobState::Queued, 1, None, 0);
for id in [parked, squeezed, small] {
assert_eq!(state.jobs[&id].status.state, JobState::Queued);
}
let choice = choose(&mut state);
assert_eq!(
choice.start,
Some(small),
"the queue must continue behind a job that keeps no capacity"
);
let head = state.head.as_ref().expect("the pass must record a head");
assert_eq!(
head.id, parked,
"the head is the FIRST job that cannot start, and not the last one"
);
assert_eq!(head.blocker, "parked");
assert!(!head.reserved);
let reason = state.jobs[&squeezed].status.blocked_reason.clone().unwrap();
assert!(
reason.contains("waits for cores"),
"the job behind must read its OWN cause: {reason}"
);
assert!(
!reason.contains("at the front of the queue"),
"a sentence about queue position names no cause: {reason}"
);
}
#[test]
fn a_job_behind_a_large_job_gets_the_remedy_for_capacity() {
let mut state = state_with(JobState::Running, None, 0);
state.queue.clear();
state.jobs.clear();
state.cfg.queue.max_bypass = 0;
add_job(&mut state, JobState::Running, 3, None, 0);
add_job(&mut state, JobState::Queued, 2, None, 0);
let behind = add_job(&mut state, JobState::Queued, 1, Some(5), 600);
let choice = choose(&mut state);
assert_eq!(choice.start, None, "no job can start with one core free");
assert_eq!(choice.finished, 1, "the job behind must give up");
let job = &state.jobs[&behind];
assert_eq!(job.status.state, JobState::Expired);
let text = job.status.error.clone().unwrap();
assert!(
text.contains("smaller claim"),
"this job waited for CAPACITY, and it has no `needs` at all: {text}"
);
assert!(
!text.contains("whole pipeline"),
"this job has no pipeline to cover: {text}"
);
}
#[test]
fn a_job_that_waited_for_a_lock_gets_the_remedy_for_a_lock() {
let mut state = state_with(JobState::Running, None, 0);
state.queue.clear();
state.jobs.clear();
let holder = add_job(&mut state, JobState::Running, 1, None, 0);
state.jobs.get_mut(&holder).unwrap().spec.locks = vec!["shared".to_string()];
let blocked = add_job(&mut state, JobState::Queued, 1, Some(5), 600);
state.jobs.get_mut(&blocked).unwrap().spec.locks = vec!["shared".to_string()];
let choice = choose(&mut state);
assert_eq!(choice.start, None, "the lock stops the only queued job");
assert_eq!(choice.finished, 1, "the job that waited must give up");
let text = state.jobs[&blocked].status.error.clone().unwrap();
assert!(
text.contains("lock"),
"the remedy must name the lock: {text}"
);
assert!(
!text.contains("smaller claim"),
"the machine had three free cores; a smaller claim changes nothing: {text}"
);
}
#[test]
fn a_lock_behind_a_job_that_waits_for_capacity_gets_the_lock_remedy() {
let mut state = state_with(JobState::Running, None, 0);
state.queue.clear();
state.jobs.clear();
let holder = add_job(&mut state, JobState::Running, 1, None, 0);
state.jobs.get_mut(&holder).unwrap().spec.locks = vec!["shared".to_string()];
add_job(&mut state, JobState::Queued, 4, None, 0);
let victim = add_job(&mut state, JobState::Queued, 1, Some(5), 600);
state.jobs.get_mut(&victim).unwrap().spec.locks = vec!["shared".to_string()];
let choice = choose(&mut state);
assert_eq!(choice.start, None, "no job can start");
assert_eq!(choice.finished, 1, "the victim must give up");
let text = state.jobs[&victim].status.error.clone().unwrap();
assert!(
text.contains("lock"),
"the victim waited for a LOCK, and the remedy must say so: {text}"
);
assert!(
!text.contains("smaller claim"),
"a smaller claim gives a lock to nobody: {text}"
);
}
#[test]
fn a_job_that_waits_for_a_dependency_gets_the_pipeline_remedy_through_choose() {
let mut state = state_with(JobState::Running, None, 0);
state.queue.clear();
state.jobs.clear();
let root = add_job(&mut state, JobState::Running, 1, None, 0);
let waiter = add_job(&mut state, JobState::Queued, 1, Some(5), 600);
state.jobs.get_mut(&waiter).unwrap().spec.needs = vec![root];
let choice = choose(&mut state);
assert_eq!(choice.finished, 1, "the job that waited must give up");
let text = state.jobs[&waiter].status.error.clone().unwrap();
assert!(
text.contains("whole pipeline"),
"a job that waited for a job that it needs takes the pipeline remedy: {text}"
);
assert!(
!text.contains("smaller claim"),
"the machine had free cores; a smaller claim changes nothing: {text}"
);
}
#[test]
fn a_pass_that_skips_a_job_reports_it_to_the_waiters() {
let mut state = state_with(JobState::Running, None, 0);
state.queue.clear();
state.jobs.clear();
let root = add_job(&mut state, JobState::Failed, 1, None, 0);
let after = add_job(&mut state, JobState::Queued, 1, None, 0);
state.jobs.get_mut(&after).unwrap().spec.needs = vec![root];
let choice = choose(&mut state);
assert_eq!(
state.jobs[&after].status.state,
JobState::Skipped,
"the job that needed a failed job must be skipped"
);
assert_eq!(
choice.finished, 1,
"a skipped job is a final state that this pass wrote, so the pass \
must report it and wake the waiters"
);
}
#[test]
fn a_job_that_waits_more_than_its_limit_expires() {
let mut state = state_with(JobState::Queued, Some(60), 61);
let id = state.queue[0];
let overdue = overdue(&state, None);
assert_eq!(overdue.len(), 1, "the job passed its limit");
expire(
&mut state,
id,
overdue[0].1,
"waits for cores: 4 of 4 are in use",
Waited::Capacity,
);
let job = &state.jobs[&id];
assert_eq!(job.status.state, JobState::Expired);
assert!(job.status.finished_at.is_some());
assert!(state.queue.is_empty(), "an expired job leaves the queue");
assert_eq!(
job.status.blocked_reason, None,
"an expired job must hold no queue reason"
);
let reason = job.status.error.clone().unwrap();
assert!(reason.contains("cores"), "got: {reason}");
assert!(reason.contains("--max-queue-time"), "got: {reason}");
assert!(
reason.contains("did not start"),
"the text must say that the job never ran: {reason}"
);
}
#[test]
fn the_remedy_fits_the_reason_for_the_wait() {
let mut state = state_with(JobState::Queued, Some(60), 61);
let id = state.queue[0];
expire(
&mut state,
id,
61,
"waits for cores: 4 of 4 are in use",
Waited::Capacity,
);
let text = state.jobs[&id].status.error.clone().unwrap();
assert!(
text.contains("smaller claim"),
"a job that waited for capacity needs the claim remedy: {text}"
);
let mut state = state_with(JobState::Queued, Some(60), 61);
let id = state.queue[0];
expire(
&mut state,
id,
61,
"waits for the job 1a2b3c4d at the front of the queue",
Waited::Capacity,
);
let text = state.jobs[&id].status.error.clone().unwrap();
assert!(
text.contains("smaller claim"),
"a job behind a large job waited for CAPACITY: {text}"
);
assert!(
!text.contains("whole pipeline"),
"this job has no pipeline to cover: {text}"
);
let mut state = state_with(JobState::Queued, Some(60), 61);
let id = state.queue[0];
expire(
&mut state,
id,
61,
"waits for the job 1a2b3c4d (build), which is running",
Waited::AJob,
);
let text = state.jobs[&id].status.error.clone().unwrap();
assert!(
text.contains("whole pipeline"),
"a job that waited for a dependency needs the pipeline remedy: {text}"
);
assert!(
!text.contains("smaller claim"),
"a smaller claim changes nothing for a job that waits for a job: {text}"
);
let mut state = state_with(JobState::Queued, Some(60), 61);
let id = state.queue[0];
expire(
&mut state,
id,
61,
"waits for the lock `shared`, which the job 1a2b3c4d (build) holds",
Waited::ALock,
);
let text = state.jobs[&id].status.error.clone().unwrap();
assert!(
text.contains("lock"),
"a job that waited for a lock must be told about the lock: {text}"
);
assert!(
!text.contains("smaller claim"),
"a smaller claim gives a lock to nobody: {text}"
);
assert!(
!text.contains("whole pipeline"),
"a lock is not a pipeline: {text}"
);
}
#[test]
fn a_job_inside_its_limit_stays_in_the_queue() {
let state = state_with(JobState::Queued, Some(600), 10);
assert!(overdue(&state, None).is_empty());
let state = state_with(JobState::Queued, None, 100_000);
assert!(
overdue(&state, None).is_empty(),
"a job with no limit must wait"
);
}
#[test]
fn a_job_expires_in_the_second_that_it_reaches_its_limit() {
let state = state_with(JobState::Queued, Some(60), 59);
assert!(
overdue(&state, None).is_empty(),
"a job one second below its limit must wait"
);
let state = state_with(JobState::Queued, Some(60), 60);
assert_eq!(
overdue(&state, None).len(),
1,
"a job that reached its limit exactly must give up"
);
}
#[test]
fn a_job_that_holds_a_start_time_never_expires() {
let mut state = state_with(JobState::Queued, Some(1), 3600);
let id = state.queue[0];
state.jobs.get_mut(&id).unwrap().status.started_at = Some(sys::now_secs() - 3000);
expire(&mut state, id, 3600, "waits for cores", Waited::Capacity);
assert_eq!(
state.jobs[&id].status.state,
JobState::Queued,
"a job that already ran must keep its state"
);
}
#[test]
fn a_job_that_started_never_expires() {
let state = state_with(JobState::Queued, Some(1), 3600);
let chosen = state.queue[0];
assert!(
overdue(&state, Some(chosen)).is_empty(),
"the job that starts now must not expire"
);
for started in [
JobState::Starting,
JobState::Running,
JobState::Completed,
JobState::Failed,
] {
let mut state = state_with(started, Some(1), 3600);
let id = state.queue[0];
assert!(
overdue(&state, None).is_empty(),
"a job in the state {started} must not be in the list"
);
expire(&mut state, id, 3600, "waits for cores", Waited::Capacity);
assert_eq!(
state.jobs[&id].status.state, started,
"a job in the state {started} must keep it"
);
}
}
#[test]
fn the_queue_tests_the_claim_that_the_job_holds_now() {
let cfg = cfg_with("4", "1GB");
let m = machine_for(&cfg);
assert!(matches!(
admit(&cfg, 1, 600 << 20, 1, 400 << 20, &m),
Admit::Yes
));
let reason = wait_reason(
admit(&cfg, 1, 1 << 30, 1, 400 << 20, &m),
"a raised claim must wait for capacity",
);
assert!(reason.contains("memory"), "got: {reason}");
assert_eq!(size_check(&cfg, 1, 1 << 30), Size::Fits);
assert!(matches!(admit(&cfg, 1, 1 << 30, 0, 0, &m), Admit::Yes));
}
#[test]
fn the_pressure_limit_stops_a_job() {
let mut cfg = cfg_with("4", "8GB");
cfg.system.max_pressure = -1.0;
if sys::memory_pressure().is_some() {
let m = machine_for(&cfg);
let reason = wait_reason(
admit(&cfg, 1, 1 << 20, 0, 0, &m),
"the pressure limit must stop this job",
);
assert!(reason.contains("pressure"), "got: {reason}");
}
}
fn cfg_with_peer(
cpu: &str,
mem: &str,
peer_cpu: u64,
peer_mem: u64,
) -> (Config, std::path::PathBuf) {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!(
"qex-sched-peer-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o1777)).unwrap();
let uid = crate::peers::current_uid();
let mine = dir.join(format!("u{uid}"));
std::fs::create_dir_all(&mine).unwrap();
let peer = serde_json::json!({
"uid": uid,
"pid": 1,
"boot_id": sys::boot_id(),
"cpu": peer_cpu,
"mem": peer_mem,
"updated_at": sys::now_secs(),
});
std::fs::write(mine.join("peer-1.json"), serde_json::to_vec(&peer).unwrap()).unwrap();
let cfg: Config = toml::from_str(&format!(
"[budget]\ncpu = \"{cpu}\"\nmem = \"{mem}\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[peers]\nenabled = true\ndir = \"{}\"\nstale_after = \"1h\"\n",
dir.display()
))
.unwrap();
(cfg, dir)
}
#[test]
fn a_job_that_another_user_holds_back_says_so_and_never_keeps_capacity() {
let (cfg, dir) = cfg_with_peer("4", "256MB", 3, 0);
let machine = Machine::read(&cfg);
assert_eq!(machine.peers.count, 1, "the test peer must count");
let Admit::No {
blocker,
reason,
held_reason,
} = admit(&cfg, 4, 64 << 20, 0, 0, &machine)
else {
panic!("a job of 4 cores must not start while another user holds 3");
};
assert_eq!(blocker, Blocker::Peer { count: 1 });
assert!(
reason.contains("another user holds capacity"),
"the reason must name the other user: {reason}"
);
assert!(
reason.contains("no known end"),
"the reason must say that qex cannot schedule the release: {reason}"
);
assert!(
held_reason.is_none() && !blocker.may_reserve(),
"a job that another user holds back must never keep the capacity"
);
assert!(matches!(
admit(&cfg, 1, 64 << 20, 0, 0, &machine),
Admit::Yes
));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_queue_keeps_capacity_only_for_a_holder_that_it_schedules() {
assert!(Blocker::Sibling.may_reserve());
assert!(Blocker::OversizedWaitsForIdle.may_reserve());
assert!(!Blocker::Peer { count: 1 }.may_reserve());
assert!(!Blocker::Machine.may_reserve());
assert!(!Blocker::OversizedParked.may_reserve());
}
#[test]
fn a_job_that_the_config_parks_does_not_stop_the_jobs_behind_it() {
let mut cfg = cfg_with("2", "256MB");
cfg.queue.oversized = OversizedPolicy::Queue;
let machine = Machine::read(&cfg);
let Verdict::Wait {
blocker, reason, ..
} = verdict(&cfg, 64, 64 << 20, 0, 0, &machine, false)
else {
panic!("a job of 64 cores must not start with a budget of 2");
};
assert_eq!(blocker, Blocker::OversizedParked);
assert!(
reason.contains("starts the jobs behind it"),
"the reason must say that the queue continues: {reason}"
);
}
#[test]
fn a_job_that_waits_for_a_quiet_machine_keeps_the_capacity() {
let cfg = cfg_with("2", "256MB");
let machine = Machine::read(&cfg);
let Verdict::Wait {
blocker,
held_reason,
..
} = verdict(&cfg, 64, 64 << 20, 0, 0, &machine, false)
else {
panic!("a job of 64 cores must not start on a busy machine");
};
assert_eq!(blocker, Blocker::OversizedWaitsForIdle);
assert!(held_reason.is_some());
assert!(matches!(
verdict(&cfg, 64, 64 << 20, 0, 0, &machine, true),
Verdict::Start
));
}
#[test]
fn a_sibling_wait_says_if_another_job_can_pass_it() {
let cfg = cfg_with("4", "256MB");
let machine = Machine::read(&cfg);
let Admit::No {
blocker,
reason,
held_reason,
} = admit(&cfg, 4, 64 << 20, 2, 0, &machine)
else {
panic!("a job of 4 cores must not start while 2 are in use");
};
assert_eq!(blocker, Blocker::Sibling);
assert!(
reason.contains("qex can start a smaller job before this one"),
"got: {reason}"
);
let held = held_reason.expect("a sibling wait has a text for a job that keeps capacity");
assert!(
held.contains("qex starts no other job before this one"),
"got: {held}"
);
assert!(!held.contains("time(s)"), "got: {held}");
}
#[test]
fn the_class_of_a_wait_uses_the_claim_in_force() {
let cfg = cfg_with("4", "256MB");
let machine = Machine::read(&cfg);
let Verdict::Wait { blocker, .. } = verdict(&cfg, 4, 64 << 20, 2, 0, &machine, false)
else {
panic!("a job of 4 cores must not start while 2 are in use");
};
assert_eq!(blocker, Blocker::Sibling);
let Verdict::Wait { blocker, .. } = verdict(&cfg, 4, 512 << 20, 2, 0, &machine, false)
else {
panic!("a claim above the budget must not start");
};
assert_eq!(blocker, Blocker::OversizedWaitsForIdle);
}
}