use crate::config::Config;
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 last_contact: Instant,
pub idle_since: Option<Instant>,
pub next_sequence: u64,
pub started_at: u64,
pub stop: bool,
}
fn rank(state: JobState) -> u8 {
match state {
JobState::Queued => 0,
JobState::Starting => 1,
JobState::Running => 2,
_ => 3,
}
}
impl State {
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) -> (u64, u64) {
self.jobs
.values()
.filter(|j| j.status.state.is_active())
.fold((0, 0), |(c, m), j| (c + j.status.cpu, m + j.status.mem))
}
pub fn count_state(&self, f: impl Fn(JobState) -> bool) -> usize {
self.jobs.values().filter(|j| f(j.status.state)).count()
}
}
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(),
last_contact: Instant::now(),
idle_since: Some(Instant::now()),
next_sequence: 1,
started_at: crate::sys::now_secs(),
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.with_context(|| format!("opening the socket {}", 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));
}
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;
}
}
{
let cfg = coord.state.lock().unwrap().cfg.clone();
crate::peers::withdraw(&cfg);
}
std::fs::remove_file(&socket_path).ok();
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();
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_alive = status.supervisor_pid.map(sys::pid_alive).unwrap_or(false);
if job_alive || supervisor_alive {
if supervisor_alive {
if let Some(pid) = status.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
));
}
}
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 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 response = match serde_json::from_str::<Request>(&line) {
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();
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();
match state.jobs.get(&id) {
Some(j) => Response::Status {
status: Box::new(j.status.clone()),
},
None => no_such_job(id),
}
}
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),
}
}
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 (cpu_claimed, mem_claimed) = state.claimed();
Response::Info {
pid: std::process::id() as i32,
version: env!("CARGO_PKG_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),
cpu_claimed,
mem_claimed,
}
}
fn handle_submit(coord: &Arc<Coordinator>, spec: JobSpec) -> Response {
let id = spec.id;
{
let 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."
),
);
}
}
}
let dir = match paths::job_dir(&id) {
Ok(d) => d,
Err(e) => return Response::error(ErrorKind::Internal, e.to_string()),
};
let mut status = JobStatus::new(&spec);
let warning = {
let state = coord.state.lock().unwrap();
match crate::sched::size_check(&state.cfg, &spec) {
crate::sched::Size::Fits => None,
crate::sched::Size::TooBig(reason) => {
use crate::config::OversizedPolicy;
match state.cfg.queue.oversized {
OversizedPolicy::Reject => {
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 Err(e) = (|| -> Result<()> {
paths::ensure_dir(&dir, 0o700)?;
job::write_spec(&dir, &spec)?;
job::write_status(&dir, &status)?;
Ok(())
})() {
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();
let priority = spec.priority;
status.sequence = state.next_sequence;
state.next_sequence += 1;
state.jobs.insert(
id,
Job {
spec,
status,
supervisor_pid: None,
},
);
let pos = state
.queue
.iter()
.position(|other| {
state
.jobs
.get(other)
.map(|j| j.spec.priority < priority)
.unwrap_or(false)
})
.unwrap_or(state.queue.len());
state.queue.insert(pos, id);
}
crate::history::record_submit_for(&id, &name_for_history, submitted_at);
coord.notify();
Response::Submitted { id, warning }
}
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);
drop(state);
if let Ok(dir) = paths::job_dir(&id) {
job::write_status(&dir, &status).ok();
}
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();
}