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 stop: bool,
}
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
}
pub fn reload_config(state: &mut State, read: ConfigFile) {
let now = config_fingerprint(&read);
if now == state.config_seen {
state.config_settling = None;
return;
}
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 {
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 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 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,
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_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
));
}
}
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();
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);
}
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: 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,
}
}
fn handle_submit(coord: &Arc<Coordinator>, spec: JobSpec) -> Response {
let id = spec.id;
let mut status = JobStatus::new(&spec);
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);
}
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 => {
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."
))
}
}
}
}
};
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();
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,
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);
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();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::JobSpec;
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,
tags: vec![],
priority: 0,
env_capture: crate::config::EnvCapture::None,
claim_source: "explicit".into(),
group: None,
group_name: None,
locks: vec![],
retries: 0,
nice: None,
needs: vec![],
after: vec![],
dedupe_key: Some(key.to_string()),
dedupe_window: 0,
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,
stop: false,
config_seen: 0,
config_settling: None,
config_error: None,
}
}
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);
}
}