use crate::job::JobStatus;
use crate::spec::JobSpec;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Request {
Ping,
Submit { spec: Box<JobSpec> },
List,
Status { id: uuid::Uuid },
Wait { id: uuid::Uuid },
Kill {
id: uuid::Uuid,
signal: i32,
grace_secs: u64,
},
Cancel { id: uuid::Uuid },
Clean { id: uuid::Uuid },
Info,
Capabilities,
Events { since: crate::events::Cursor },
Pause {
target: PauseTarget,
reason: Option<String>,
until: Option<u64>,
#[serde(default)]
by_pid: i32,
},
Resume { target: PauseTarget },
PauseState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PauseTarget {
Queue,
Lock { name: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockPause {
pub name: String,
pub record: crate::pause::PauseRecord,
pub held_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueHealth {
pub last_start_at: Option<u64>,
pub peer_count: usize,
pub peer_cpu: u64,
pub peer_mem: u64,
pub head_job: Option<String>,
pub head_blocker: Option<String>,
pub head_passed_by: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum Response {
Ok,
Submitted {
id: uuid::Uuid,
warning: Option<String>,
#[serde(default)]
deduplicated: bool,
},
Jobs { jobs: Vec<JobStatus> },
Status { status: Box<JobStatus> },
Info {
pid: i32,
version: String,
#[serde(default)]
started_at: u64,
#[serde(default)]
program_replaced: bool,
jobs_running: usize,
jobs_queued: usize,
cpu_budget: u64,
mem_budget: u64,
#[serde(default)]
config_error: Option<String>,
cpu_claimed: u64,
mem_claimed: u64,
#[serde(default)]
queue_state: Option<String>,
#[serde(default)]
paused_at: Option<u64>,
#[serde(default)]
paused_by_pid: Option<i32>,
#[serde(default)]
paused_reason: Option<String>,
#[serde(default)]
paused_until: Option<u64>,
#[serde(default)]
paused_locks: Option<Vec<LockPause>>,
#[serde(default)]
health: Option<Box<QueueHealth>>,
#[serde(default)]
pools: Option<Vec<PoolReport>>,
},
Capabilities { names: Vec<String> },
Event { event: Box<crate::events::Event> },
PauseState {
queue: Option<crate::pause::PauseRecord>,
locks: Vec<LockPause>,
},
Error { message: String, kind: ErrorKind },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolReport {
pub name: String,
pub total: u64,
pub used: u64,
pub peer_used: u64,
#[serde(default)]
pub size_name: Option<String>,
#[serde(default)]
pub devices: Vec<DeviceReport>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceReport {
pub index: u32,
pub capacity: u64,
pub used: u64,
pub peer: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
NoSuchJob,
WrongState,
Internal,
}
impl Response {
pub fn error(kind: ErrorKind, message: impl Into<String>) -> Self {
Self::Error {
message: message.into(),
kind,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_wire_format_stays_additive() {
let later = r#"{
"result": "info",
"version": "9.9.9",
"pid": 1,
"cpu_budget": 4,
"mem_budget": 1024,
"cpu_claimed": 0,
"mem_claimed": 0,
"jobs_running": 0,
"jobs_queued": 0,
"a_field_from_a_later_release": {"anything": [1, 2, 3]}
}"#;
let answer: Response =
serde_json::from_str(later).expect("an older CLI must read a newer answer");
match answer {
Response::Info { version, pid, .. } => {
assert_eq!(version, "9.9.9");
assert_eq!(pid, 1);
}
other => panic!("the answer became {other:?}"),
}
let later = r#"{"op": "status", "id": "00000000-0000-4000-8000-000000000000",
"a_field_from_a_later_release": true}"#;
let request: Request =
serde_json::from_str(later).expect("an older coordinator must read a newer request");
assert!(matches!(request, Request::Status { .. }));
let spec = 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: None,
dedupe_window: 0,
learn_key: None,
submitted_at: 0,
};
let record = JobStatus::new(&spec);
let mut later = serde_json::to_value(&record).unwrap();
later["a_field_from_a_later_release"] = serde_json::json!({"anything": 1});
let answer: Response = serde_json::from_value(serde_json::json!({
"result": "status",
"status": later,
}))
.expect("an older CLI must read a newer record");
match answer {
Response::Status { status } => assert_eq!(status.name, record.name),
other => panic!("the answer became {other:?}"),
}
}
#[test]
fn each_message_survives_one_line_of_json() {
let id = uuid::Uuid::new_v4();
let requests = [
Request::Ping,
Request::List,
Request::Status { id },
Request::Wait { id },
Request::Kill {
id,
signal: 15,
grace_secs: 10,
},
Request::Cancel { id },
Request::Clean { id },
Request::Info,
Request::Capabilities,
Request::Events {
since: crate::events::Cursor::After {
seq: 7,
stream: Some(id),
},
},
Request::Pause {
target: PauseTarget::Queue,
reason: Some("recording a demo".into()),
until: Some(1_700_000_000),
by_pid: 4321,
},
Request::Pause {
target: PauseTarget::Lock {
name: "gpu0".into(),
},
reason: None,
until: None,
by_pid: 4321,
},
Request::Resume {
target: PauseTarget::Queue,
},
Request::PauseState,
];
for r in requests {
let line = serde_json::to_string(&r).unwrap();
assert!(!line.contains('\n'), "a message must fit one line: {line}");
let back: Request = serde_json::from_str(&line).unwrap();
assert_eq!(
serde_json::to_string(&back).unwrap(),
line,
"the message changed after a round trip"
);
}
}
#[test]
fn an_error_response_keeps_its_kind() {
let r = Response::error(ErrorKind::NoSuchJob, "there is no job with that id");
let line = serde_json::to_string(&r).unwrap();
let back: Response = serde_json::from_str(&line).unwrap();
match back {
Response::Error { kind, message } => {
assert_eq!(kind, ErrorKind::NoSuchJob);
assert!(message.contains("no job"));
}
other => panic!("expected an error, got {other:?}"),
}
}
#[test]
fn an_unknown_message_is_refused_and_not_accepted() {
let err = serde_json::from_str::<Request>(r#"{"op":"explode"}"#);
assert!(err.is_err(), "the parser must refuse an unknown operation");
}
}