use crate::config::{Config, ConfigFile};
use crate::job::{self, JobState, JobStatus};
use crate::paths;
use crate::proto::{ErrorKind, Request, Response};
use crate::spec::JobSpec;
use crate::sys;
use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
const IDLE_EXIT: Duration = Duration::from_secs(3600);
const IDLE_EXIT_VAR: &str = "QEX_IDLE_EXIT_SECS";
pub struct Job {
pub spec: JobSpec,
pub status: JobStatus,
pub supervisor_pid: Option<i32>,
}
pub struct State {
pub cfg: Config,
pub jobs: BTreeMap<uuid::Uuid, Job>,
pub queue: Vec<uuid::Uuid>,
pub dedupe: BTreeMap<String, uuid::Uuid>,
pub last_contact: Instant,
pub idle_since: Option<Instant>,
pub next_sequence: u64,
pub started_at: u64,
pub config_seen: u64,
pub config_settling: Option<(u64, Instant)>,
pub config_error: Option<String>,
pub events: crate::events::EventLog,
pub paused: crate::pause::Paused,
pub last_start_at: Option<u64>,
pub head: Option<HeadInfo>,
pub peer_claims: crate::peers::Claims,
pub stop: bool,
}
#[derive(Debug, Clone)]
pub struct HeadInfo {
pub id: uuid::Uuid,
pub name: String,
pub blocker: String,
pub reserved: bool,
pub passed_by: u32,
}
pub const CONFIG_SETTLE: Duration = Duration::from_millis(500);
fn config_fingerprint(read: &ConfigFile) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let mut eat = |byte: u8| {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
};
match read {
ConfigFile::Missing => eat(0),
ConfigFile::NotRegular => eat(1),
ConfigFile::Unreadable(_) => eat(3),
ConfigFile::Text(bytes, _) => {
eat(2);
for byte in bytes {
eat(*byte);
}
}
}
hash
}
const WAITING_FOR_A_WRITER: &str =
"a writer changes the configuration file now, so qex waits for it. The coordinator keeps \
the values that it had.";
pub fn reload_config(state: &mut State, read: ConfigFile) {
let now = config_fingerprint(&read);
if now == state.config_seen {
state.config_settling = None;
if state.config_error.as_deref() == Some(WAITING_FOR_A_WRITER) {
state.config_error = None;
}
return;
}
let the_file_says_it_settled = match &read {
ConfigFile::Text(_, Some(age)) => {
if *age < CONFIG_SETTLE {
state.config_settling = None;
if state.config_error.as_deref() != Some(WAITING_FOR_A_WRITER) {
log(WAITING_FOR_A_WRITER);
}
state.config_error = Some(WAITING_FOR_A_WRITER.to_string());
return;
}
true
}
_ => false,
};
if !the_file_says_it_settled {
match state.config_settling {
Some((seen, since)) if seen == now => {
if since.elapsed() < CONFIG_SETTLE {
return;
}
}
_ => {
state.config_settling = Some((now, Instant::now()));
return;
}
}
}
state.config_settling = None;
state.config_seen = now;
let bytes = match read {
ConfigFile::Text(bytes, _) if !bytes.is_empty() => bytes,
ConfigFile::Text(..) | ConfigFile::Missing | ConfigFile::Unreadable(_) => {
let message = "the file is empty, or qex cannot read it".to_string();
log(&format!(
"{message}. The coordinator keeps the values that it had."
));
state.config_error = Some(message);
return;
}
ConfigFile::NotRegular => {
let message = "the path of the configuration file is not a regular file".to_string();
log(&format!(
"{message}. The coordinator keeps the values that it had."
));
state.config_error = Some(message);
return;
}
};
let path = paths::config_file().unwrap_or_default();
let text = match String::from_utf8(bytes) {
Ok(text) => text,
Err(_) => {
let message = "the configuration file is not text".to_string();
log(&format!(
"{message}. The coordinator keeps the values that it had."
));
state.config_error = Some(message);
return;
}
};
match Config::parse_short(&path, &text).and_then(|c| c.validate().map(|_| c)) {
Ok(cfg) => {
state.config_error = None;
log("the configuration file changed; the coordinator read it again");
state.cfg = cfg;
}
Err(e) => {
let message = format!("{e:#}");
log(&format!(
"the configuration file changed and qex cannot read it: {message}. \
The coordinator keeps the values that it had."
));
state.config_error = Some(message);
}
}
}
fn rank(state: JobState) -> u8 {
match state {
JobState::Queued => 0,
JobState::Starting => 1,
JobState::Running => 2,
_ => 3,
}
}
impl State {
#[cfg(test)]
fn for_a_test() -> Self {
Self {
cfg: Config::default(),
jobs: BTreeMap::new(),
queue: Vec::new(),
dedupe: BTreeMap::new(),
last_contact: Instant::now(),
idle_since: None,
next_sequence: 1,
started_at: 0,
config_seen: 0,
config_settling: None,
config_error: None,
events: crate::events::EventLog::new(),
paused: crate::pause::Paused::default(),
last_start_at: None,
head: None,
peer_claims: crate::peers::Claims::default(),
stop: false,
}
}
pub fn refresh_active(&mut self) -> bool {
let ids: Vec<uuid::Uuid> = self
.jobs
.iter()
.filter(|(_, j)| !j.status.state.is_terminal())
.map(|(id, _)| *id)
.collect();
let mut changed = false;
for id in ids {
let Ok(dir) = paths::job_dir(&id) else {
continue;
};
let Ok(disk) = job::read_status(&dir) else {
continue;
};
let Some(job) = self.jobs.get_mut(&id) else {
continue;
};
if job.status.state == JobState::Queued && disk.state == JobState::Queued {
continue;
}
if rank(disk.state) < rank(job.status.state) {
continue;
}
if job.status.state != disk.state
|| job.status.pid != disk.pid
|| job.status.exit_code != disk.exit_code
{
changed = true;
}
job.status = disk;
}
changed
}
pub fn claimed(&self) -> crate::sched::Held {
let pools = self.cfg.pools().unwrap_or_default();
let mut held = crate::sched::Held::default();
for job in self.jobs.values() {
if job.status.state.is_active() {
held.add(&job.status, &pools);
}
}
held
}
pub fn dedupe_holder(&self, key: &str, window: u64) -> Option<uuid::Uuid> {
let id = *self.dedupe.get(key)?;
let Some(job) = self.jobs.get(&id) else {
return Some(id);
};
if !job.status.state.is_terminal() {
return Some(id);
}
if window > 0 && job.status.state == JobState::Completed {
let finished = job.status.finished_at.unwrap_or(0);
if sys::now_secs().saturating_sub(finished) < window {
return Some(id);
}
}
None
}
pub fn count_state(&self, f: impl Fn(JobState) -> bool) -> usize {
self.jobs.values().filter(|j| f(j.status.state)).count()
}
pub fn lock_holder(&self, name: &str) -> Option<String> {
self.jobs
.values()
.find(|j| j.status.state.is_active() && j.spec.locks.iter().any(|l| l == name))
.map(|j| {
format!(
"{} ({})",
&j.status.id.to_string()[..8],
j.status.name.clone()
)
})
}
pub fn paused_locks(&self) -> Vec<crate::proto::LockPause> {
self.paused
.locks
.iter()
.map(|(name, record)| crate::proto::LockPause {
name: name.clone(),
record: record.clone(),
held_by: self.lock_holder(name),
})
.collect()
}
pub fn save_pause(&self) {
if let Err(e) = self.paused.write() {
log(&format!("qex could not write the pause record: {e:#}"));
}
}
pub fn enqueue(&mut self, id: uuid::Uuid) {
if self.queue.contains(&id) {
return;
}
let priority = self.jobs.get(&id).map(|j| j.spec.priority).unwrap_or(0);
let pos = self
.queue
.iter()
.position(|other| {
self.jobs
.get(other)
.map(|j| j.spec.priority < priority)
.unwrap_or(false)
})
.unwrap_or(self.queue.len());
self.queue.insert(pos, id);
}
}
#[cfg(unix)]
fn update_watch(coord: Arc<Coordinator>) {
let mut step = Duration::from_secs(2);
loop {
std::thread::sleep(step);
let cfg = {
let state = match coord.state.lock() {
Ok(state) => state,
Err(_) => return,
};
if state.stop {
return;
}
state.cfg.clone()
};
crate::update::check_if_due(&cfg);
step = crate::update::interval(&cfg)
.ok()
.flatten()
.unwrap_or(Duration::from_secs(60))
.clamp(Duration::from_secs(1), Duration::from_secs(60));
}
}
pub struct Coordinator {
pub state: Mutex<State>,
pub changed: Condvar,
}
impl Coordinator {
fn new(cfg: Config) -> Self {
Self {
state: Mutex::new(State {
cfg,
jobs: BTreeMap::new(),
queue: Vec::new(),
dedupe: BTreeMap::new(),
last_contact: Instant::now(),
idle_since: Some(Instant::now()),
next_sequence: 1,
started_at: crate::sys::now_secs(),
config_seen: config_fingerprint(&crate::config::read_config_file()),
config_settling: None,
config_error: None,
events: crate::events::EventLog::new(),
paused: crate::pause::Paused::default(),
last_start_at: None,
head: None,
peer_claims: crate::peers::Claims::default(),
stop: false,
}),
changed: Condvar::new(),
}
}
pub fn notify(&self) {
self.changed.notify_all();
}
}
pub fn run() -> Result<()> {
let cfg = Config::load()?;
cfg.validate()?;
if crate::enforce::restart_with_systemd(&cfg) {
log("the coordinator starts again in a systemd unit, to get a cgroup that it owns");
return Ok(());
}
paths::reap_stale_socket_dirs();
let runtime = paths::runtime_dir()?;
paths::ensure_dir(&runtime, 0o700)?;
paths::ensure_dir(&paths::jobs_dir()?, 0o700)?;
let socket_path = paths::socket_path()?;
if socket_path.exists() {
if UnixStream::connect(&socket_path).is_ok() {
log("a different coordinator operates; this process stops");
return Ok(());
}
std::fs::remove_file(&socket_path).ok();
}
let listener = {
let previous = unsafe { libc::umask(0o177) };
let result = UnixListener::bind(&socket_path);
unsafe {
libc::umask(previous);
}
result.map_err(|e| {
let note = if matches!(
e.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Unsupported
) {
"\nA sandbox that refuses a Unix socket gives this fault. See \
https://github.com/stephenc/qex/blob/main/docs/sandbox.md"
} else {
""
};
anyhow::anyhow!("opening the socket {}: {e}{note}", socket_path.display())
})?
};
restrict_socket(&socket_path)?;
if let Some(warning) = crate::enforce::startup_warning(&cfg) {
log(&format!("warning: {warning}"));
}
crate::history::prune(&cfg);
let coord = Arc::new(Coordinator::new(cfg));
recover(&coord)?;
log(&format!(
"the coordinator started; pid {}; socket {}",
std::process::id(),
socket_path.display()
));
{
let coord = Arc::clone(&coord);
std::thread::spawn(move || crate::sched::run(coord));
}
{
let coord = Arc::clone(&coord);
let path = socket_path.clone();
std::thread::spawn(move || idle_watch(coord, path));
}
{
let coord = Arc::clone(&coord);
std::thread::spawn(move || update_watch(coord));
}
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let coord = Arc::clone(&coord);
std::thread::spawn(move || {
if let Err(e) = serve(coord, stream) {
log(&format!("a connection failed: {e:#}"));
}
});
}
Err(e) => {
log(&format!(
"the coordinator could not accept a connection: {e}"
));
}
}
if coord.state.lock().unwrap().stop {
break;
}
}
std::fs::remove_file(&socket_path).ok();
crate::events::wait_for_readers(&coord, Duration::from_secs(1));
{
let cfg = coord.state.lock().unwrap().cfg.clone();
crate::peers::withdraw(&cfg);
}
log("the coordinator stopped");
Ok(())
}
fn restrict_socket(path: &std::path::Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("setting the mode of {}", path.display()))
}
fn recover(coord: &Arc<Coordinator>) -> Result<()> {
let dir = paths::jobs_dir()?;
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return Ok(()),
};
let mut state = coord.state.lock().unwrap();
let mut recovered = 0usize;
let mut queued = Vec::new();
state.paused = crate::pause::Paused::read();
let now = sys::now_secs();
let ended_while_down = state
.paused
.queue
.clone()
.filter(|record| record.expired(now));
if state.paused.expire(now) {
state.save_pause();
}
match &state.paused.queue {
Some(record) if record.fault => {
log(&format!(
"qex could not read its pause record, so it holds the queue: {}",
record.reason.as_deref().unwrap_or("unknown")
));
state.save_pause();
}
Some(record) => log(&format!(
"the queue is paused; a person paused it at {}",
sys::clock_text(record.paused_at)
)),
None => {}
}
for name in state.paused.locks.keys() {
log(&format!(
"a person holds the lock `{}`",
crate::job::safe_name(name)
));
}
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let (spec, mut status) = match (job::read_spec(&path), job::read_status(&path)) {
(Ok(s), Ok(st)) => (s, st),
_ => continue,
};
if status.state.is_active() {
let job_alive = status.pid.map(sys::pid_alive).unwrap_or(false);
let supervisor_pid = status
.supervisor_pid
.or_else(|| crate::supervisor::supervisor_pid_of(&path));
let supervisor_alive = supervisor_pid.map(sys::pid_alive).unwrap_or(false);
if job_alive || supervisor_alive {
if supervisor_alive {
if let Some(pid) = supervisor_pid {
let coord2 = Arc::clone(coord);
let id = status.id;
std::thread::spawn(move || crate::supervisor::reap(coord2, id, pid));
}
}
} else {
status.state = JobState::Failed;
status.finished_at = Some(sys::now_secs());
status.blocked_reason = None;
status.error = Some(
"the coordinator stopped, and neither the job nor its supervisor continued"
.to_string(),
);
job::write_status(&path, &status).ok();
log(&format!(
"job {} was active but its processes are gone; the state is now failed",
status.id
));
crate::hook::fire_detached(&path, &status);
}
}
if status.state == JobState::Queued {
queued.push((status.id, status.submitted_at, spec.priority));
}
state.jobs.insert(
status.id,
Job {
spec,
status,
supervisor_pid: None,
},
);
recovered += 1;
}
state.next_sequence = state
.jobs
.values()
.map(|j| j.status.sequence)
.max()
.unwrap_or(0)
+ 1;
queued.sort_by(|a, b| b.2.cmp(&a.2).then(a.1.cmp(&b.1)));
state.queue = queued.into_iter().map(|(id, _, _)| id).collect();
if let Some(record) = ended_while_down {
let ended_at = record.until.unwrap_or(now).min(now);
crate::pause::end_queue_pause(&mut state, &record, ended_at);
log("a pause reached its end while no coordinator operated");
}
let mut holders: Vec<(String, uuid::Uuid, bool, u64, u64)> = state
.jobs
.values()
.filter_map(|j| {
let key = j.spec.dedupe_key.clone()?;
Some((
key,
j.status.id,
j.status.state.is_terminal(),
j.status.submitted_at,
j.status.sequence,
))
})
.collect();
holders.sort_by(|a, b| {
b.2.cmp(&a.2).then(a.3.cmp(&b.3)).then(a.4.cmp(&b.4))
});
for (key, id, ..) in holders {
state.dedupe.insert(key, id);
}
state.publish_changes();
if recovered > 0 {
log(&format!("the coordinator read {recovered} job record(s)"));
}
Ok(())
}
fn serve(coord: Arc<Coordinator>, stream: UnixStream) -> Result<()> {
let mut writer = stream.try_clone().context("copying the socket handle")?;
let reader = BufReader::new(stream);
for line in reader.lines() {
let line = line.context("reading a request")?;
if line.trim().is_empty() {
continue;
}
coord.state.lock().unwrap().last_contact = Instant::now();
let parsed = serde_json::from_str::<Request>(&line);
if let Ok(Request::Events { since }) = parsed {
crate::events::stream(&coord, &mut writer, since).ok();
break;
}
let response = match parsed {
Ok(request) => handle(&coord, request),
Err(e) => Response::error(
ErrorKind::Internal,
format!("qex could not read this request: {e}"),
),
};
let mut text = serde_json::to_string(&response).context("writing the answer")?;
text.push('\n');
if writer.write_all(text.as_bytes()).is_err() {
break;
}
writer.flush().ok();
}
Ok(())
}
fn handle(coord: &Arc<Coordinator>, request: Request) -> Response {
match request {
Request::Ping => Response::Ok,
Request::Info => handle_info(coord),
Request::Capabilities => Response::Capabilities {
names: crate::capabilities::ALL
.iter()
.map(|s| s.to_string())
.collect(),
},
Request::Submit { spec } => handle_submit(coord, *spec),
Request::List => {
let mut state = coord.state.lock().unwrap();
state.refresh_active();
state.publish_changes();
Response::Jobs {
jobs: state.jobs.values().map(|j| j.status.clone()).collect(),
}
}
Request::Status { id } => {
let mut state = coord.state.lock().unwrap();
state.refresh_active();
state.publish_changes();
match state.jobs.get(&id) {
Some(j) => Response::Status {
status: Box::new(j.status.clone()),
},
None => no_such_job(id),
}
}
Request::Events { .. } => Response::error(
ErrorKind::Internal,
"qex could not open the event stream on this connection. Run `qex events` again.",
),
Request::Wait { id } => handle_wait(coord, id),
Request::Cancel { id } => handle_cancel(coord, id),
Request::Kill {
id,
signal,
grace_secs,
} => crate::lifecycle::kill(coord, id, signal, grace_secs),
Request::Clean { id } => crate::lifecycle::clean(coord, id),
Request::Pause {
target,
reason,
until,
by_pid,
} => handle_pause(coord, target, reason, until, by_pid),
Request::Resume { target } => handle_resume(coord, target),
Request::PauseState => {
let state = coord.state.lock().unwrap();
Response::PauseState {
queue: state.paused.queue.clone(),
locks: state.paused_locks(),
}
}
}
}
fn handle_pause(
coord: &Arc<Coordinator>,
target: crate::proto::PauseTarget,
reason: Option<String>,
until: Option<u64>,
by_pid: i32,
) -> Response {
use crate::proto::PauseTarget;
let mut state = coord.state.lock().unwrap();
match target {
PauseTarget::Queue => {
let record = keep_the_end(state.paused.queue.take(), by_pid, reason, until);
state.paused.queue = Some(record);
log("a person paused the queue; qex starts no job");
}
PauseTarget::Lock { name } => {
let record = keep_the_end(state.paused.locks.remove(&name), by_pid, reason, until);
log(&format!(
"a person asked for the lock `{}`",
crate::job::safe_name(&name)
));
state.paused.locks.insert(name, record);
}
}
state.save_pause();
let answer = Response::PauseState {
queue: state.paused.queue.clone(),
locks: state.paused_locks(),
};
drop(state);
coord.notify();
answer
}
fn keep_the_end(
old: Option<crate::pause::PauseRecord>,
by_pid: i32,
reason: Option<String>,
until: Option<u64>,
) -> crate::pause::PauseRecord {
let mut record = crate::pause::PauseRecord::new(by_pid, reason, until);
if let Some(old) = old.filter(|o| !o.fault) {
record.paused_at = old.paused_at;
record.by_pid = old.by_pid;
record.reason = record.reason.or(old.reason);
record.until = record.until.or(old.until);
}
record
}
fn handle_resume(coord: &Arc<Coordinator>, target: crate::proto::PauseTarget) -> Response {
use crate::proto::PauseTarget;
let mut state = coord.state.lock().unwrap();
match target {
PauseTarget::Queue => {
if let Some(record) = state.paused.queue.take() {
crate::pause::end_queue_pause(&mut state, &record, crate::sys::now_secs());
}
log("a person started the queue again");
}
PauseTarget::Lock { name } => {
log(&format!(
"a person gave the lock `{}` back",
crate::job::safe_name(&name)
));
state.paused.locks.remove(&name);
}
}
state.save_pause();
let answer = Response::PauseState {
queue: state.paused.queue.clone(),
locks: state.paused_locks(),
};
drop(state);
coord.notify();
answer
}
fn no_such_job(id: uuid::Uuid) -> Response {
Response::error(
ErrorKind::NoSuchJob,
format!("there is no job with the id {id}"),
)
}
fn handle_info(coord: &Arc<Coordinator>) -> Response {
let state = coord.state.lock().unwrap();
let queue_state = match &state.paused.queue {
Some(record) if record.fault => "paused-by-fault".to_string(),
Some(_) => "paused".to_string(),
None => match &state.head {
None => "running".to_string(),
Some(h) if h.reserved => "held".to_string(),
Some(h) => h.blocker.clone(),
},
};
let held = state.claimed();
let (cpu_claimed, mem_claimed) = (held.cpu, held.mem);
let peers = if state.cfg.peers.enabled {
crate::peers::claims(&state.cfg)
} else {
crate::peers::Claims::default()
};
let pools: Vec<crate::proto::PoolReport> = state
.cfg
.pools()
.unwrap_or_default()
.into_iter()
.map(|p| crate::proto::PoolReport {
devices: p
.devices
.iter()
.enumerate()
.map(|(i, capacity)| crate::proto::DeviceReport {
index: i as u32,
capacity: *capacity,
used: held
.devices
.get(&p.name)
.and_then(|m| m.get(&(i as u32)))
.copied()
.unwrap_or(0),
peer: peers
.devices
.get(&p.name)
.map(|s| s.contains(&(i as u32)))
.unwrap_or(false),
})
.collect(),
used: held.pools.get(&p.name).copied().unwrap_or(0),
peer_used: peers.pools.get(&p.name).copied().unwrap_or(0),
total: p.total,
size_name: p.size_name,
name: p.name,
})
.collect();
Response::Info {
pools: Some(pools),
pid: std::process::id() as i32,
version: crate::version::VERSION.to_string(),
started_at: state.started_at,
program_replaced: paths::program_file_changed(),
jobs_running: state.count_state(|s| s.is_active()),
jobs_queued: state.count_state(|s| s == JobState::Queued),
cpu_budget: state.cfg.budget_cpu().unwrap_or(0),
mem_budget: state.cfg.budget_mem().unwrap_or(0),
config_error: state.config_error.clone(),
cpu_claimed,
mem_claimed,
queue_state: Some(queue_state),
paused_at: state.paused.queue.as_ref().map(|p| p.paused_at),
paused_by_pid: state.paused.queue.as_ref().map(|p| p.by_pid),
paused_reason: state.paused.queue.as_ref().and_then(|p| p.reason.clone()),
paused_until: state.paused.queue.as_ref().and_then(|p| p.until),
paused_locks: Some(state.paused_locks()),
health: Some(Box::new(crate::proto::QueueHealth {
last_start_at: state.last_start_at,
peer_count: state.peer_claims.count,
peer_cpu: state.peer_claims.cpu,
peer_mem: state.peer_claims.mem,
head_job: state
.head
.as_ref()
.map(|h| format!("{} ({})", &h.id.to_string()[..8], h.name)),
head_blocker: state.head.as_ref().map(|h| h.blocker.clone()),
head_passed_by: state.head.as_ref().map(|h| h.passed_by),
})),
}
}
fn handle_submit(coord: &Arc<Coordinator>, spec: JobSpec) -> Response {
let id = spec.id;
let mut status = JobStatus::new(&spec);
let mut pause_warning: Option<String> = None;
let mut pause_reason: Option<String> = None;
let warning = {
let mut state = coord.state.lock().unwrap();
for dep in spec.needs.iter().chain(spec.after.iter()) {
if !state.jobs.contains_key(dep) {
return Response::error(
ErrorKind::NoSuchJob,
format!(
"the job {dep} does not exist, so this job cannot wait for it.\n\
Start that job first, and give the id that `qex submit` wrote."
),
);
}
}
if let Some(key) = spec.dedupe_key.clone() {
state.refresh_active();
if let Some(other) = state.dedupe_holder(&key, spec.dedupe_window) {
let doing = match state.jobs.get(&other) {
Some(j) => format!("is in the state `{}`", j.status.state),
None => String::from("starts now"),
};
let shown = crate::job::safe_name(&key);
log(&format!(
"a submission with the dedupe key `{shown}` gave the job {other}"
));
return Response::Submitted {
id: other,
warning: Some(format!(
"this submission started no job. The dedupe key `{shown}` gives the job \
{other}, and that job {doing}.\n\
qex gives you the id of that job, so `qex wait` and `qex status` \
operate on the work that already exists.\n\
A key names the work, and qex does not compare the command. Run \
`qex status {other}` to see the work that this id names.\n\
To run the work a second time, wait for that job to stop, or use a \
different key."
)),
deduplicated: true,
};
}
state.dedupe.insert(key, id);
}
let mut lines = Vec::new();
if let Some(record) = &state.paused.queue {
pause_reason = Some(crate::pause::queue_reason(record));
lines.push(
"the queue is paused, so this job waits. Run `qex resume queue` to start the \
queue again."
.to_string(),
);
}
for name in &spec.locks {
if state.paused.locks.contains_key(name) {
let shown = crate::job::safe_name(name);
lines.push(format!(
"a person holds the lock `{shown}`, so this job waits. Run \
`qex resume lock {shown}` to give it back."
));
}
}
if !lines.is_empty() {
pause_warning = Some(lines.join("\n"));
}
if let Err(reason) = crate::sched::pool_check(&state.cfg, &spec) {
release_dedupe(&mut state, id);
return Response::error(ErrorKind::WrongState, reason);
}
match crate::sched::size_check(&state.cfg, spec.cpu, spec.mem) {
crate::sched::Size::Fits => None,
crate::sched::Size::TooBig(reason) => {
use crate::config::OversizedPolicy;
match state.cfg.queue.oversized {
OversizedPolicy::Reject => {
release_dedupe(&mut state, id);
return Response::error(
ErrorKind::WrongState,
format!(
"{reason}\nThe config file sets [queue] oversized = \"reject\". \
Decrease the claim, or increase [budget]."
),
);
}
OversizedPolicy::Queue => Some(format!(
"{reason}\nThe config file sets [queue] oversized = \"queue\". \
This job waits until you change the budget."
)),
OversizedPolicy::RunWhenIdle => {
status.blocked_reason = Some(reason.clone());
Some(format!(
"{reason}\nqex starts this job alone when no other job operates. \
The job can swap, use every core, or stop with an out-of-memory \
error. Read `qex status {id}` for the result."
))
}
}
}
}
};
if let Some(reason) = &pause_reason {
status.blocked_reason = Some(reason.clone());
}
let warning = match (pause_warning, warning) {
(Some(pause), Some(size)) => Some(format!("{pause}\n{size}")),
(Some(pause), None) => Some(pause),
(None, size) => size,
};
let dir = match paths::job_dir(&id) {
Ok(d) => d,
Err(e) => {
release_dedupe(&mut coord.state.lock().unwrap(), id);
return Response::error(ErrorKind::Internal, e.to_string());
}
};
if let Err(e) = (|| -> Result<()> {
paths::ensure_dir(&dir, 0o700)?;
job::write_spec(&dir, &spec)?;
job::write_status(&dir, &status)?;
Ok(())
})() {
release_dedupe(&mut coord.state.lock().unwrap(), id);
return Response::error(
ErrorKind::Internal,
format!("qex could not write the job record: {e:#}"),
);
}
let name_for_history = spec.name.clone();
let submitted_at = spec.submitted_at;
{
let mut state = coord.state.lock().unwrap();
status.sequence = state.next_sequence;
state.next_sequence += 1;
state.jobs.insert(
id,
Job {
spec,
status,
supervisor_pid: None,
},
);
state.enqueue(id);
state.publish_changes();
}
crate::history::record_submit_for(&id, &name_for_history, submitted_at);
coord.notify();
Response::Submitted {
id,
warning,
deduplicated: false,
}
}
pub fn release_dedupe(state: &mut State, id: uuid::Uuid) {
state.dedupe.retain(|_, holder| *holder != id);
}
fn handle_wait(coord: &Arc<Coordinator>, id: uuid::Uuid) -> Response {
let mut state = coord.state.lock().unwrap();
if !state.jobs.contains_key(&id) {
return no_such_job(id);
}
loop {
match state.jobs.get(&id) {
Some(j) if j.status.state.is_terminal() => {
return Response::Status {
status: Box::new(j.status.clone()),
}
}
Some(_) => {}
None => return no_such_job(id),
}
let (guard, _) = coord
.changed
.wait_timeout(state, Duration::from_secs(30))
.unwrap();
state = guard;
}
}
fn handle_cancel(coord: &Arc<Coordinator>, id: uuid::Uuid) -> Response {
let mut state = coord.state.lock().unwrap();
let Some(job) = state.jobs.get_mut(&id) else {
return no_such_job(id);
};
match job.status.state {
JobState::Queued => {
job.status.state = JobState::Cancelled;
job.status.finished_at = Some(sys::now_secs());
job.status.blocked_reason = None;
let status = job.status.clone();
state.queue.retain(|q| *q != id);
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();
Response::Ok
}
JobState::Starting | JobState::Running => Response::error(
ErrorKind::WrongState,
format!("the job {id} operates now. Use `qex kill {id}` to stop it."),
),
other => Response::error(
ErrorKind::WrongState,
format!("the job {id} is in the state `{other}`, so qex cannot cancel it"),
),
}
}
fn idle_watch(coord: Arc<Coordinator>, socket: std::path::PathBuf) {
let idle_limit = std::env::var(IDLE_EXIT_VAR)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(IDLE_EXIT);
loop {
std::thread::sleep(Duration::from_secs(1).min(idle_limit));
let replaced = paths::program_file_changed();
let should_stop = {
let mut state = coord.state.lock().unwrap();
let active = state.count_state(|s| !s.is_terminal());
let idle = active == 0 && (replaced || state.last_contact.elapsed() >= idle_limit);
if idle {
state.stop = true;
}
idle
};
if should_stop && replaced {
log(
"the qex program file changed; this coordinator stops so that the next \
command starts one with the new program",
);
}
if should_stop {
log("the coordinator is idle and stops");
UnixStream::connect(&socket).ok();
return;
}
}
}
pub fn log(message: &str) {
println!("[{}] {message}", sys::now_secs());
use std::io::Write as _;
std::io::stdout().flush().ok();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::JobSpec;
#[test]
fn a_file_that_a_writer_changed_a_moment_ago_is_not_taken() {
let young = ConfigFile::Text(b"[budget]\ncpu = \"1\"\n".to_vec(), Some(Duration::ZERO));
let mut state = State::for_a_test();
let before = state.config_seen;
for _ in 0..10 {
reload_config(&mut state, clone_of(&young));
}
assert_eq!(
state.config_seen, before,
"a file that is younger than the settle time must not become the configuration"
);
let old = ConfigFile::Text(b"[budget]\ncpu = \"1\"\n".to_vec(), Some(CONFIG_SETTLE * 2));
reload_config(&mut state, clone_of(&old));
assert_ne!(
state.config_seen, before,
"a file that settled must become the configuration"
);
}
#[test]
fn two_looks_at_one_young_file_far_apart_still_do_not_take_it() {
let young = ConfigFile::Text(b"[budget]\ncpu = \"1\"\n".to_vec(), Some(Duration::ZERO));
let mut state = State::for_a_test();
let before = state.config_seen;
reload_config(&mut state, clone_of(&young));
if let Some((_, since)) = &mut state.config_settling {
*since = Instant::now() - CONFIG_SETTLE * 2;
}
reload_config(&mut state, clone_of(&young));
assert_eq!(
state.config_seen, before,
"a file that a writer touched a moment ago must not become the configuration, \
whatever time passed between two looks"
);
assert!(
state.config_error.is_some(),
"qex must say that it waits for a writer"
);
}
#[test]
fn a_file_with_no_time_still_settles_by_its_content() {
let file = ConfigFile::Text(b"[budget]\ncpu = \"1\"\n".to_vec(), None);
let mut state = State::for_a_test();
let before = state.config_seen;
reload_config(&mut state, clone_of(&file));
assert_eq!(state.config_seen, before);
if let Some((_, since)) = &mut state.config_settling {
*since = Instant::now() - CONFIG_SETTLE * 2;
}
reload_config(&mut state, clone_of(&file));
assert_ne!(
state.config_seen, before,
"a file with no time must still reach the coordinator"
);
}
fn clone_of(file: &ConfigFile) -> ConfigFile {
match file {
ConfigFile::Text(bytes, age) => ConfigFile::Text(bytes.clone(), *age),
ConfigFile::Missing => ConfigFile::Missing,
ConfigFile::NotRegular => ConfigFile::NotRegular,
ConfigFile::Unreadable(_) => ConfigFile::NotRegular,
}
}
fn spec_with_key(key: &str) -> JobSpec {
JobSpec {
id: uuid::Uuid::new_v4(),
name: "t".into(),
cwd: "/".into(),
command: vec!["true".into()],
env: Default::default(),
cpu: 1,
mem: 1 << 20,
timeout: None,
max_queue_time: None,
tags: vec![],
priority: 0,
env_capture: crate::config::EnvCapture::None,
claim_source: "explicit".into(),
group: None,
group_name: None,
locks: vec![],
claims: Default::default(),
retries: 0,
nice: None,
needs: vec![],
after: vec![],
dedupe_key: Some(key.to_string()),
dedupe_window: 0,
learn_key: None,
submitted_at: 0,
}
}
fn empty_state() -> State {
State {
cfg: Config::default(),
jobs: BTreeMap::new(),
queue: Vec::new(),
dedupe: BTreeMap::new(),
last_contact: Instant::now(),
idle_since: None,
next_sequence: 1,
started_at: 0,
paused: crate::pause::Paused::default(),
last_start_at: None,
head: None,
peer_claims: Default::default(),
stop: false,
config_seen: 0,
config_settling: None,
config_error: None,
events: crate::events::EventLog::new(),
}
}
fn add(
state: &mut State,
key: &str,
job_state: JobState,
finished_at: Option<u64>,
) -> uuid::Uuid {
let spec = spec_with_key(key);
let id = spec.id;
let mut status = JobStatus::new(&spec);
status.state = job_state;
status.finished_at = finished_at;
state.jobs.insert(
id,
Job {
spec,
status,
supervisor_pid: None,
},
);
state.dedupe.insert(key.to_string(), id);
id
}
#[test]
fn a_key_holds_a_job_that_waits_or_operates() {
for job_state in [JobState::Queued, JobState::Starting, JobState::Running] {
let mut state = empty_state();
let id = add(&mut state, "build:/x", job_state, None);
assert_eq!(
state.dedupe_holder("build:/x", 0),
Some(id),
"a job in the state `{job_state}` must hold its key"
);
}
}
#[test]
fn a_key_with_no_job_is_free() {
let mut state = empty_state();
add(&mut state, "build:/x", JobState::Running, None);
assert_eq!(state.dedupe_holder("build:/y", 0), None);
assert_eq!(state.dedupe_holder("", 0), None);
}
#[test]
fn a_job_that_stopped_frees_its_key() {
for job_state in [
JobState::Completed,
JobState::Failed,
JobState::Killed,
JobState::Timeout,
JobState::Oom,
JobState::Cancelled,
JobState::Skipped,
] {
let mut state = empty_state();
add(&mut state, "build:/x", job_state, Some(sys::now_secs()));
assert_eq!(
state.dedupe_holder("build:/x", 0),
None,
"a job in the state `{job_state}` must free its key"
);
}
}
#[test]
fn the_window_keeps_the_key_of_a_job_that_succeeded_only() {
let now = sys::now_secs();
let mut state = empty_state();
let id = add(&mut state, "k", JobState::Completed, Some(now));
assert_eq!(state.dedupe_holder("k", 3600), Some(id));
let mut state = empty_state();
add(&mut state, "k", JobState::Completed, Some(now - 7200));
assert_eq!(state.dedupe_holder("k", 3600), None);
for job_state in [JobState::Failed, JobState::Timeout, JobState::Oom] {
let mut state = empty_state();
add(&mut state, "k", job_state, Some(now));
assert_eq!(
state.dedupe_holder("k", 3600),
None,
"a job in the state `{job_state}` must free its key inside the window"
);
}
}
#[test]
fn a_key_goes_at_the_end_of_the_window_and_not_after_it() {
const WINDOW: u64 = 600;
for _ in 0..100 {
let now = sys::now_secs();
let mut state = empty_state();
add(&mut state, "k", JobState::Completed, Some(now - WINDOW));
let answer = state.dedupe_holder("k", WINDOW);
if sys::now_secs() != now {
continue;
}
assert_eq!(
answer, None,
"a job that succeeded exactly one window ago must give the key back"
);
let mut state = empty_state();
let id = add(&mut state, "k", JobState::Completed, Some(now - WINDOW + 1));
let answer = state.dedupe_holder("k", WINDOW);
if sys::now_secs() != now {
continue;
}
assert_eq!(
answer,
Some(id),
"a job that succeeded inside the window must keep the key"
);
return;
}
panic!("the clock moved on every attempt, so the edge was never tested");
}
#[test]
fn a_key_that_a_submission_reserved_is_taken_before_the_record_exists() {
let mut state = empty_state();
let id = uuid::Uuid::new_v4();
state.dedupe.insert("k".into(), id);
assert_eq!(state.dedupe_holder("k", 0), Some(id));
}
#[test]
fn the_key_goes_when_the_record_goes() {
let mut state = empty_state();
let id = add(&mut state, "k", JobState::Completed, Some(sys::now_secs()));
state.jobs.remove(&id);
release_dedupe(&mut state, id);
assert!(state.dedupe.is_empty());
assert_eq!(state.dedupe_holder("k", 3600), None);
}
#[test]
fn a_second_pause_keeps_the_end_and_the_reason_of_the_first() {
let first = crate::pause::PauseRecord {
paused_at: 1_000,
by_pid: 11,
reason: Some("recording a demo".into()),
until: Some(2_800),
fault: false,
};
let second = keep_the_end(Some(first.clone()), 22, None, None);
assert_eq!(second.until, Some(2_800), "the end must stay");
assert_eq!(second.reason.as_deref(), Some("recording a demo"));
assert_eq!(second.paused_at, 1_000, "the pause began at the first one");
let third = keep_the_end(Some(first.clone()), 22, Some("a build".into()), Some(9_000));
assert_eq!(third.until, Some(9_000));
assert_eq!(third.reason.as_deref(), Some("a build"));
let fault = crate::pause::PauseRecord {
fault: true,
..first
};
let real = keep_the_end(Some(fault), 22, None, None);
assert_eq!(real.until, None);
assert_eq!(real.by_pid, 22);
assert!(!real.fault);
}
}