use crate::config::{Config, OversizedPolicy, Pool};
use crate::daemon::{log, Coordinator};
use crate::job::{self, Assignment, JobState};
use crate::paths;
use crate::spec::{JobSpec, PoolClaim};
use crate::sys;
use crate::units::format_size;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Size {
Fits,
TooBig(String),
}
pub fn effective_claims(spec: &JobSpec) -> BTreeMap<String, PoolClaim> {
let mut all = spec.claims.clone();
for name in &spec.locks {
all.entry(name.clone()).or_insert(PoolClaim {
count: 1,
size: None,
});
}
all
}
fn pool_of(pools: &[Pool], name: &str) -> Pool {
pools
.iter()
.find(|p| p.name == name)
.cloned()
.unwrap_or_else(|| Pool::implicit(name))
}
fn is_a_lock(pools: &[Pool], name: &str) -> bool {
!pools.iter().any(|p| p.name == name)
}
pub fn pool_check(cfg: &Config, spec: &JobSpec) -> Result<(), String> {
let pools = cfg.pools().map_err(|e| e.to_string())?;
let claims = effective_claims(spec);
for (name, claim) in &claims {
let declared = pools.iter().find(|p| p.name == *name);
if claim.count == 0 {
let quantity = declared
.and_then(|p| p.size_name.clone())
.unwrap_or_else(|| "VRAM".to_string());
return Err(format!(
"this job claims {} of {quantity} and claims no device. {quantity} is a quantity \
on each device, so a job must also claim a device. Add `--gpu 1`.",
format_size(claim.size.unwrap_or(0)),
));
}
let Some(pool) = declared else {
if name == crate::config::GPU_POOL {
return Err(format!(
"there is no pool `{name}` in the configuration, so qex cannot give this \
job a GPU. Add a pool to ~/.config/qex.toml:\n\n\
\x20 [[pool]]\n\
\x20 name = \"gpu\"\n\
\x20 size = \"vram\"\n\
\x20 devices = [\"24GB\", \"24GB\"]\n\n\
Then start the job again."
));
}
if claim.count > 1 || claim.size.is_some() {
return Err(format!(
"the job claims {} of `{name}`, and the configuration does not declare \
that pool, so qex treats it as a lock of size 1. This job can never \
start. Add the pool to ~/.config/qex.toml:\n\n\
\x20 [[pool]]\n\
\x20 name = \"{name}\"\n\
\x20 count = 4\n\n\
Then start the job again.",
claim.count
));
}
continue;
};
if claim.size.is_some() && !pool.is_indexed() {
return Err(format!(
"the pool `{name}` has no devices, so it holds no size. This job can never \
start. Give `--claim {name}=N` only."
));
}
if claim.count > pool.total {
return Err(format!(
"the job claims {} of the pool `{name}` and the pool has {}. This job can \
never start. Claim {} or fewer, or add the devices to `[[pool]]` in \
~/.config/qex.toml.",
claim.count, pool.total, pool.total
));
}
if let Some(size) = claim.size {
if size > pool.largest_device() {
return Err(format!(
"the job claims {} of {} for each device, and the largest device of the \
pool `{name}` has {}. qex does not add the memory of the devices \
together, so this job can never start. Claim {} or less.",
format_size(size),
pool.size_name.clone().unwrap_or_else(|| "size".to_string()),
format_size(pool.largest_device()),
format_size(pool.largest_device())
));
}
}
}
Ok(())
}
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>,
},
}
#[derive(Debug, Clone, Default)]
pub struct Held {
pub cpu: u64,
pub mem: u64,
pub pools: BTreeMap<String, u64>,
pub devices: BTreeMap<String, BTreeMap<u32, u64>>,
}
impl Held {
pub fn add(&mut self, status: &crate::job::JobStatus, pools: &[Pool]) {
self.cpu += status.cpu;
self.mem += status.mem;
for (name, given) in &status.assigned {
*self.pools.entry(name.clone()).or_insert(0) += given.units;
if given.devices.is_empty() {
continue;
}
let pool = pool_of(pools, name);
let per_device = self.devices.entry(name.clone()).or_default();
for index in &given.devices {
let capacity = pool.devices.get(*index as usize).copied().unwrap_or(0);
*per_device.entry(*index).or_insert(0) += given.size.unwrap_or(capacity);
}
}
for name in &status.locks {
if !status.assigned.contains_key(name) {
*self.pools.entry(name.clone()).or_insert(0) += 1;
}
}
}
pub fn pool_units(&self) -> BTreeMap<String, u64> {
self.pools.clone()
}
pub fn device_indices(&self) -> BTreeMap<String, Vec<u32>> {
self.devices
.iter()
.map(|(name, used)| (name.clone(), used.keys().copied().collect()))
.collect()
}
}
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, pools: &[Pool], spec: &JobSpec) -> Option<String> {
let claims = effective_claims(spec);
if claims.is_empty() {
return None;
}
for name in &spec.locks {
if state.paused.locks.contains_key(name) {
return Some(crate::pause::lock_reason(name));
}
}
for name in claims.keys() {
if !is_a_lock(pools, name) {
continue;
}
for job in state.jobs.values() {
if !job.status.state.is_active() {
continue;
}
if job.spec.locks.contains(name) || job.spec.claims.contains_key(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 free_devices(
pool: &Pool,
claim: &PoolClaim,
held: &Held,
peers: &crate::peers::Claims,
) -> Vec<(u32, u64)> {
let ours = held.devices.get(&pool.name);
let theirs: Option<&BTreeSet<u32>> = peers.devices.get(&pool.name);
let mut free: Vec<(u32, u64)> = pool
.devices
.iter()
.enumerate()
.filter_map(|(i, capacity)| {
let index = i as u32;
if theirs.map(|t| t.contains(&index)).unwrap_or(false) {
return None;
}
let used = ours.and_then(|m| m.get(&index)).copied().unwrap_or(0);
let left = capacity.saturating_sub(used);
let needed = claim.size.unwrap_or(*capacity);
if left >= needed && needed > 0 {
Some((index, left))
} else {
None
}
})
.collect();
free.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
free
}
struct PoolWait {
blocker: Blocker,
reason: String,
held_reason: Option<String>,
}
fn pool_wait(
pools: &[Pool],
claims: &BTreeMap<String, PoolClaim>,
held: &Held,
peers: &crate::peers::Claims,
) -> Option<PoolWait> {
for (name, claim) in claims {
if is_a_lock(pools, name) {
continue;
}
let pool = pool_of(pools, name);
let peer_units = peers.pools.get(name).copied().unwrap_or(0);
let peer_devices = peers.devices.get(name).map(|d| d.len()).unwrap_or(0);
let by_peer = peer_units > 0 || peer_devices > 0;
let (short, arithmetic) = if pool.is_indexed() {
let free = free_devices(&pool, claim, held, peers).len() as u64;
let each = match claim.size {
Some(size) => format!(" with {} free each", format_size(size)),
None => " that is free in full".to_string(),
};
(
free < claim.count,
format!(
"this job needs {} device(s){each}, the pool has {}, and {free} can hold this \
job now.",
claim.count, pool.total
),
)
} else {
let ours = held.pools.get(name).copied().unwrap_or(0);
let free = pool.total.saturating_sub(ours).saturating_sub(peer_units);
(
claim.count > free,
format!(
"this job needs {}, the pool has {}, and the jobs of this queue hold {ours}.",
claim.count, pool.total
),
)
};
if !short {
continue;
}
return Some(if by_peer {
PoolWait {
blocker: Blocker::Peer { count: peers.count },
reason: format!(
"this job cannot fit while another user holds the pool `{name}`: \
{arithmetic} {} part of it. qex does not control that user, so this wait has \
no known end. qex starts the jobs behind this one while the pool is not \
free. Read `qex info` for the pools of this machine.",
other_users(peers.count)
),
held_reason: None,
}
} else {
PoolWait {
blocker: Blocker::Sibling,
reason: format!(
"waits for the pool `{name}`: {arithmetic} Those jobs release the pool when \
they stop. qex can start a job that does not need this pool before this one."
),
held_reason: Some(format!(
"waits for the pool `{name}`: {arithmetic} qex starts no other job before \
this one. Read `qex list` to see the jobs that hold the pool."
)),
}
});
}
None
}
fn assign(
pools: &[Pool],
claims: &BTreeMap<String, PoolClaim>,
held: &Held,
peers: &crate::peers::Claims,
) -> Result<BTreeMap<String, Assignment>, String> {
let mut out = BTreeMap::new();
for (name, claim) in claims {
let pool = pool_of(pools, name);
if !pool.is_indexed() {
out.insert(
name.clone(),
Assignment {
units: claim.count,
devices: Vec::new(),
size: None,
},
);
continue;
}
let free = free_devices(&pool, claim, held, peers);
if (free.len() as u64) < claim.count {
return Err(format!(
"waits for the pool `{name}`: this job needs {} device(s), and {} can hold \
it now",
claim.count,
free.len()
));
}
let mut devices: Vec<u32> = free
.iter()
.take(claim.count as usize)
.map(|(index, _)| *index)
.collect();
devices.sort_unstable();
out.insert(
name.clone(),
Assignment {
units: claim.count,
devices,
size: claim.size,
},
);
}
Ok(out)
}
fn admit(
cfg: &Config,
pools: &[Pool],
claims: &BTreeMap<String, PoolClaim>,
cpu: u64,
mem: u64,
held: &Held,
machine: &Machine,
) -> Admit {
let cpu_budget = cfg.budget_cpu().unwrap_or(1);
let mem_budget = cfg.budget_mem().unwrap_or(0);
let (cpu_used, mem_used) = (held.cpu, held.mem);
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,
};
}
}
if let Some(wait) = pool_wait(pools, claims, held, &machine.peers) {
return Admit::No {
blocker: wait.blocker,
reason: wait.reason,
held_reason: wait.held_reason,
};
}
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>,
},
}
#[allow(clippy::too_many_arguments)]
fn verdict(
cfg: &Config,
pools: &[Pool],
spec: &JobSpec,
cpu: u64,
mem: u64,
held: &Held,
machine: &Machine,
quiet: bool,
) -> Verdict {
if let Err(reason) = pool_check(cfg, spec) {
return Verdict::Wait {
blocker: Blocker::OversizedParked,
reason: format!(
"{reason}\nThe configuration changed after the submission of this job. This job \
never starts, so qex starts the jobs behind it."
),
held_reason: None,
};
}
let claims = effective_claims(spec);
match size_check(cfg, cpu, mem) {
Size::Fits => match admit(cfg, pools, &claims, cpu, mem, held, 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 held = state.claimed();
let cfg = state.cfg.clone();
drop(state);
crate::peers::publish(
&cfg,
held.cpu,
held.mem,
held.pool_units(),
held.device_indices(),
);
}
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 held = state.claimed();
let cfg = state.cfg.clone();
let pools = cfg.pools().unwrap_or_default();
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, &pools, &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, &pools, &job.spec, claim_cpu, claim_mem, &held, &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(());
}
if let Err(reason) = pool_check(&state.cfg, &job.spec) {
if let Some(job) = state.jobs.get_mut(&id) {
job.status.blocked_reason = Some(reason);
}
state.publish_changes();
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 pools = state.cfg.pools().unwrap_or_default();
let held = state.claimed();
let peers = if state.cfg.peers.enabled {
crate::peers::claims(&state.cfg)
} else {
crate::peers::Claims::default()
};
let Some(job) = state.jobs.get(&id) else {
return Ok(());
};
let assigned = match assign(&pools, &effective_claims(&job.spec), &held, &peers) {
Ok(a) => a,
Err(reason) => {
if let Some(job) = state.jobs.get_mut(&id) {
job.status.blocked_reason = Some(reason);
}
state.publish_changes();
return Ok(());
}
};
let Some(job) = state.jobs.get_mut(&id) else {
return Ok(());
};
job.status.assigned = assigned;
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 used(cpu: u64, mem: u64) -> Held {
Held {
cpu,
mem,
..Default::default()
}
}
fn admit_plain(
cfg: &Config,
cpu: u64,
mem: u64,
cpu_used: u64,
mem_used: u64,
m: &Machine,
) -> Admit {
admit(
cfg,
&[],
&BTreeMap::new(),
cpu,
mem,
&used(cpu_used, mem_used),
m,
)
}
fn verdict_plain(
cfg: &Config,
cpu: u64,
mem: u64,
cpu_used: u64,
mem_used: u64,
m: &Machine,
quiet: bool,
) -> Verdict {
verdict(
cfg,
&[],
&spec_with(cpu, mem),
cpu,
mem,
&used(cpu_used, mem_used),
m,
quiet,
)
}
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![],
claims: Default::default(),
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_plain(&cfg, cpu, mem, 2, 64 << 20, &m),
Admit::Yes
));
let reason = wait_reason(
admit_plain(&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_plain(&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_plain(&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_plain(&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_plain(&cfg, 1, 600 << 20, 1, 400 << 20, &m),
Admit::Yes
));
let reason = wait_reason(
admit_plain(&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_plain(&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_plain(&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_plain(&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_plain(&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_plain(&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_plain(&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_plain(&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_plain(&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_plain(&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_plain(&cfg, 4, 512 << 20, 2, 0, &machine, false)
else {
panic!("a claim above the budget must not start");
};
assert_eq!(blocker, Blocker::OversizedWaitsForIdle);
}
fn cfg_with_pools() -> Config {
toml::from_str(
"[budget]\ncpu = \"8\"\nmem = \"8GB\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[peers]\nenabled = false\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\n\
devices = [\"24GB\", \"24GB\", \"16GB\", \"24GB\"]\n\
env = \"CUDA_VISIBLE_DEVICES\"\n\
[[pool]]\nname = \"net\"\ncount = 4\n",
)
.unwrap()
}
fn spec_claiming(claims: &[(&str, u64, Option<u64>)]) -> JobSpec {
let mut spec = spec_with(1, 1 << 20);
for (name, count, size) in claims {
spec.claims.insert(
(*name).to_string(),
PoolClaim {
count: *count,
size: *size,
},
);
}
spec
}
fn admit_claim(cfg: &Config, pools: &[Pool], spec: &JobSpec, held: &Held) -> Admit {
admit(
cfg,
pools,
&effective_claims(spec),
spec.cpu,
spec.mem,
held,
&Machine::read(cfg),
)
}
fn hold(held: &mut Held, spec: &JobSpec, given: BTreeMap<String, Assignment>, pools: &[Pool]) {
let mut status = crate::job::JobStatus::new(spec);
status.assigned = given;
held.add(&status, pools);
}
#[test]
fn a_machine_with_no_gpu_admits_a_gpu_claim_from_the_configuration() {
let cfg = cfg_with_pools();
let pools = cfg.pools().unwrap();
let spec = spec_claiming(&[("gpu", 2, None)]);
assert!(pool_check(&cfg, &spec).is_ok());
assert_eq!(size_check(&cfg, spec.cpu, spec.mem), Size::Fits);
assert!(matches!(
admit_claim(&cfg, &pools, &spec, &Held::default()),
Admit::Yes
));
}
#[test]
fn vram_is_never_added_together_over_the_devices() {
let cfg = cfg_with_pools();
let reason = pool_check(&cfg, &spec_claiming(&[("gpu", 2, Some(40 << 30))]))
.expect_err("a claim of 40GB on each device must be impossible");
assert!(
reason.contains("never start"),
"the message must say that the job can never start: {reason}"
);
assert!(
reason.contains("24GB"),
"the message must name the largest device: {reason}"
);
assert!(pool_check(&cfg, &spec_claiming(&[("gpu", 2, Some(20 << 30))])).is_ok());
}
#[test]
fn a_claim_above_the_pool_total_can_never_start() {
let cfg = cfg_with_pools();
let reason = pool_check(&cfg, &spec_claiming(&[("gpu", 8, None)]))
.expect_err("a claim of 8 devices from a pool of 4 must be impossible");
assert!(reason.contains("never start"), "got: {reason}");
let reason = pool_check(&cfg, &spec_claiming(&[("net", 5, None)]))
.expect_err("a claim of 5 units from a pool of 4 must be impossible");
assert!(reason.contains("never start"), "got: {reason}");
}
#[test]
fn a_claim_that_can_never_start_parks_and_never_reserves() {
let cfg = cfg_with_pools();
let pools = cfg.pools().unwrap();
let spec = spec_claiming(&[("gpu", 8, None)]);
let Verdict::Wait {
blocker,
held_reason,
..
} = verdict(
&cfg,
&pools,
&spec,
spec.cpu,
spec.mem,
&Held::default(),
&Machine::read(&cfg),
false,
)
else {
panic!("a claim of 8 devices from a pool of 4 must not start");
};
assert_eq!(blocker, Blocker::OversizedParked);
assert!(
!blocker.may_reserve(),
"a job that can never start must never keep capacity"
);
assert!(
held_reason.is_none(),
"a class that never reserves must have no held text"
);
}
#[test]
fn a_pool_that_this_queue_holds_is_a_sibling_wait_and_may_reserve() {
let cfg = cfg_with_pools();
let pools = cfg.pools().unwrap();
let claim = spec_claiming(&[("net", 3, None)]);
assert!(matches!(
admit_claim(&cfg, &pools, &claim, &Held::default()),
Admit::Yes
));
let mut held = Held::default();
held.pools.insert("net".into(), 2);
let Admit::No {
blocker,
reason,
held_reason,
} = admit_claim(&cfg, &pools, &claim, &held)
else {
panic!("3 of `net` must not fit while 2 of the 4 are in use");
};
assert_eq!(blocker, Blocker::Sibling);
assert!(blocker.may_reserve());
assert!(reason.contains("net"), "got: {reason}");
assert!(
held_reason.is_some(),
"a class that may reserve must give the text for a reserved head"
);
}
#[test]
fn a_pool_that_another_user_holds_never_reserves() {
let cfg = cfg_with_pools();
let pools = cfg.pools().unwrap();
let claim = spec_claiming(&[("net", 3, None)]);
let mut peers = crate::peers::Claims {
count: 1,
..Default::default()
};
peers.pools.insert("net".into(), 2);
let machine = Machine {
available: u64::MAX / 2,
pressure: None,
peers,
};
let Admit::No {
blocker,
held_reason,
..
} = admit(
&cfg,
&pools,
&effective_claims(&claim),
claim.cpu,
claim.mem,
&Held::default(),
&machine,
)
else {
panic!("3 of `net` must not fit while another user holds 2 of the 4");
};
assert_eq!(blocker, Blocker::Peer { count: 1 });
assert!(
!blocker.may_reserve(),
"a wait on another user must never keep capacity"
);
assert!(held_reason.is_none());
}
#[test]
fn an_undeclared_pool_name_is_a_lock_and_not_an_error() {
let cfg = cfg_with_pools();
assert!(pool_check(&cfg, &spec_claiming(&[("build-dir", 1, None)])).is_ok());
let reason = pool_check(&cfg, &spec_claiming(&[("build-dir", 2, None)]))
.expect_err("2 of an undeclared pool must be impossible");
assert!(reason.contains("lock of size 1"), "got: {reason}");
}
#[test]
fn a_gpu_claim_with_no_gpu_pool_names_the_configuration() {
let cfg = cfg_with("4", "1GB");
let reason = pool_check(&cfg, &spec_claiming(&[("gpu", 1, None)]))
.expect_err("a GPU claim with no pool must be impossible");
assert!(reason.contains("[[pool]]"), "got: {reason}");
assert!(reason.contains("qex.toml"), "got: {reason}");
}
#[test]
fn vram_with_no_device_claim_is_refused() {
let cfg = cfg_with_pools();
let reason = pool_check(&cfg, &spec_claiming(&[("gpu", 0, Some(4 << 30))]))
.expect_err("VRAM with no device must be impossible");
assert!(reason.contains("--gpu 1"), "got: {reason}");
}
#[test]
fn a_size_on_a_pool_with_no_devices_is_refused() {
let cfg = cfg_with_pools();
let reason = pool_check(&cfg, &spec_claiming(&[("net", 1, Some(1 << 30))]))
.expect_err("a size on a plain pool must be impossible");
assert!(reason.contains("no devices"), "got: {reason}");
}
#[test]
fn two_jobs_get_different_devices_and_a_third_waits() {
let cfg: Config = toml::from_str(
"[budget]\ncpu = \"8\"\nmem = \"8GB\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[peers]\nenabled = false\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\ndevices = [\"24GB\", \"24GB\"]\n",
)
.unwrap();
let pools = cfg.pools().unwrap();
let claim = spec_claiming(&[("gpu", 1, None)]);
let no_peers = crate::peers::Claims::default();
let mut held = Held::default();
let first = assign(&pools, &effective_claims(&claim), &held, &no_peers).unwrap();
assert_eq!(first["gpu"].devices, vec![0]);
hold(&mut held, &claim, first, &pools);
let second = assign(&pools, &effective_claims(&claim), &held, &no_peers).unwrap();
assert_eq!(
second["gpu"].devices,
vec![1],
"the second job must get a device that the first job does not hold"
);
hold(&mut held, &claim, second, &pools);
let Admit::No { reason, .. } = admit_claim(&cfg, &pools, &claim, &held) else {
panic!("a third job must wait when both devices are in use");
};
assert!(reason.contains("gpu"), "got: {reason}");
}
#[test]
fn a_claim_with_no_vram_takes_the_whole_device() {
let cfg: Config = toml::from_str(
"[budget]\ncpu = \"8\"\nmem = \"8GB\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[peers]\nenabled = false\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\ndevices = [\"24GB\"]\n",
)
.unwrap();
let pools = cfg.pools().unwrap();
let whole = spec_claiming(&[("gpu", 1, None)]);
let no_peers = crate::peers::Claims::default();
let mut held = Held::default();
let given = assign(&pools, &effective_claims(&whole), &held, &no_peers).unwrap();
assert_eq!(given["gpu"].size, None, "a whole device records no size");
let small = spec_claiming(&[("gpu", 1, Some(1 << 30))]);
assert!(assign(&pools, &effective_claims(&small), &held, &no_peers).is_ok());
hold(&mut held, &whole, given, &pools);
assert!(
assign(&pools, &effective_claims(&small), &held, &no_peers).is_err(),
"a device that a job owns in full must hold no second job"
);
}
#[test]
fn a_device_holds_two_jobs_while_its_capacity_permits() {
let cfg: Config = toml::from_str(
"[budget]\ncpu = \"8\"\nmem = \"8GB\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[peers]\nenabled = false\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\ndevices = [\"24GB\"]\n",
)
.unwrap();
let pools = cfg.pools().unwrap();
let claim = spec_claiming(&[("gpu", 1, Some(8 << 30))]);
let no_peers = crate::peers::Claims::default();
let mut held = Held::default();
for n in 0..3 {
let given = assign(&pools, &effective_claims(&claim), &held, &no_peers)
.unwrap_or_else(|e| panic!("the job {n} of 8GB must fit a device of 24GB: {e}"));
assert_eq!(given["gpu"].devices, vec![0]);
hold(&mut held, &claim, given, &pools);
}
assert_eq!(held.devices["gpu"][&0], 24 << 30);
assert!(
assign(&pools, &effective_claims(&claim), &held, &no_peers).is_err(),
"a fourth job of 8GB must not fit a device of 24GB"
);
}
#[test]
fn the_device_with_the_most_free_capacity_comes_first() {
let cfg: Config = toml::from_str(
"[budget]\ncpu = \"8\"\nmem = \"8GB\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[peers]\nenabled = false\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\ndevices = [\"16GB\", \"24GB\", \"16GB\"]\n",
)
.unwrap();
let pools = cfg.pools().unwrap();
let claim = spec_claiming(&[("gpu", 1, Some(4 << 30))]);
let given = assign(
&pools,
&effective_claims(&claim),
&Held::default(),
&crate::peers::Claims::default(),
)
.unwrap();
assert_eq!(
given["gpu"].devices,
vec![1],
"the device with 24GB must come before the two devices with 16GB"
);
}
#[test]
fn a_device_that_another_user_holds_is_not_given_again() {
let cfg = cfg_with_pools();
let pools = cfg.pools().unwrap();
let claim = spec_claiming(&[("gpu", 4, None)]);
let no_peers = crate::peers::Claims::default();
assert!(assign(
&pools,
&effective_claims(&claim),
&Held::default(),
&no_peers
)
.is_ok());
let mut peers = crate::peers::Claims::default();
peers
.devices
.insert("gpu".into(), [0u32, 1].into_iter().collect());
peers.count = 1;
assert!(
assign(&pools, &effective_claims(&claim), &Held::default(), &peers).is_err(),
"a claim of 4 devices must fail while another user holds 2"
);
let two = spec_claiming(&[("gpu", 2, None)]);
let given = assign(&pools, &effective_claims(&two), &Held::default(), &peers).unwrap();
assert_eq!(
given["gpu"].devices,
vec![2, 3],
"qex must give the devices that no other user holds"
);
}
#[test]
fn a_lock_becomes_a_pool_of_one_unit_inside_the_coordinator() {
let mut spec = spec_with(1, 1 << 20);
spec.locks = vec!["target".into()];
let claims = effective_claims(&spec);
assert_eq!(
claims["target"],
PoolClaim {
count: 1,
size: None
}
);
assert!(
spec.claims.is_empty(),
"the wire field `claims` must stay empty for a job with a lock only"
);
assert!(is_a_lock(&[], "target"));
}
#[test]
fn a_lock_of_an_earlier_record_still_counts() {
let mut spec = spec_with(1, 1 << 20);
spec.locks = vec!["target".into()];
let status = crate::job::JobStatus::new(&spec);
assert!(status.assigned.is_empty());
let mut held = Held::default();
held.add(&status, &[]);
assert_eq!(held.pools.get("target"), Some(&1));
}
#[test]
fn a_lock_never_becomes_a_head_that_keeps_capacity() {
let mut held = Held::default();
held.pools.insert("target".into(), 1);
let mut spec = spec_with(1, 1 << 20);
spec.locks = vec!["target".into()];
assert!(
pool_wait(
&[],
&effective_claims(&spec),
&held,
&crate::peers::Claims::default()
)
.is_none(),
"a lock must not reach the capacity pass"
);
}
#[test]
fn a_declared_pool_of_one_device_is_counted_and_sees_the_other_users() {
let cfg: Config = toml::from_str(
"[budget]\ncpu = \"8\"\nmem = \"8GB\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[peers]\nenabled = false\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\ndevices = [\"24GB\"]\n",
)
.unwrap();
let pools = cfg.pools().unwrap();
let whole = spec_claiming(&[("gpu", 1, None)]);
assert!(
!is_a_lock(&pools, "gpu"),
"a declared pool is counted, whatever its size"
);
assert!(
is_a_lock(&pools, "build-dir"),
"a name that the configuration does not declare is a lock"
);
let machine_free = Machine {
available: u64::MAX / 2,
pressure: None,
peers: crate::peers::Claims::default(),
};
assert!(matches!(
admit(
&cfg,
&pools,
&effective_claims(&whole),
whole.cpu,
whole.mem,
&Held::default(),
&machine_free,
),
Admit::Yes
));
let mut peers = crate::peers::Claims {
count: 1,
..Default::default()
};
peers
.devices
.insert("gpu".into(), [0u32].into_iter().collect());
let machine_busy = Machine {
available: u64::MAX / 2,
pressure: None,
peers,
};
let Admit::No { blocker, .. } = admit(
&cfg,
&pools,
&effective_claims(&whole),
whole.cpu,
whole.mem,
&Held::default(),
&machine_busy,
) else {
panic!("a device that another user holds must not go to this job as well");
};
assert_eq!(blocker, Blocker::Peer { count: 1 });
assert!(!blocker.may_reserve());
}
#[test]
fn a_partial_claim_on_one_device_still_shares_the_device() {
let cfg: Config = toml::from_str(
"[budget]\ncpu = \"8\"\nmem = \"8GB\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[peers]\nenabled = false\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\ndevices = [\"24GB\"]\n",
)
.unwrap();
let pools = cfg.pools().unwrap();
let part = spec_claiming(&[("gpu", 1, Some(4 << 30))]);
let mut held = Held::default();
let given = assign(
&pools,
&effective_claims(&part),
&held,
&crate::peers::Claims::default(),
)
.unwrap();
hold(&mut held, &part, given, &pools);
assert!(matches!(
admit_claim(&cfg, &pools, &part, &held),
Admit::Yes
));
}
#[test]
fn a_claim_of_one_unit_excludes_in_the_same_way_as_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.claims.insert(
"port".into(),
PoolClaim {
count: 1,
size: None,
},
);
let mut spec = spec_with(1, 1 << 20);
spec.claims.insert(
"port".into(),
PoolClaim {
count: 1,
size: None,
},
);
let mut other = spec_with(1, 1 << 20);
other.claims.insert(
"other-port".into(),
PoolClaim {
count: 1,
size: None,
},
);
assert!(lock_conflict(&state, &[], &other).is_none());
let reason = lock_conflict(&state, &[], &spec)
.expect("a second claim of the one unit must wait for the first");
assert!(reason.contains("port"), "got: {reason}");
}
#[test]
fn a_counted_pool_admits_jobs_until_it_is_full() {
let cfg = cfg_with_pools();
let pools = cfg.pools().unwrap();
let claim = spec_claiming(&[("net", 3, None)]);
let mut held = Held::default();
assert!(matches!(
admit_claim(&cfg, &pools, &claim, &held),
Admit::Yes
));
held.pools.insert("net".into(), 2);
let Admit::No { reason, .. } = admit_claim(&cfg, &pools, &claim, &held) else {
panic!("3 of `net` must not fit while 2 of 4 are in use");
};
assert!(reason.contains("net"), "got: {reason}");
}
}