use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct QueueOptions {
pub wait: bool,
pub follow: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_timeout_secs: Option<u64>,
pub no_start: bool,
pub strict: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdmissionState {
AdmittedRunning,
Queued,
RejectedBeforeAdmission(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WaitResult {
StartedAfterWait,
TimedOut,
CancelledBeforeStart,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StartState {
Started,
NotStarted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "outcome")]
pub enum QueueContractOutcome {
StartedRunning,
WaitingStreamed,
QueuedNotStarted,
TimedOutQueued,
CancelledBeforeStart,
FailedBeforeAdmission { reason: String },
}
impl QueueContractOutcome {
#[must_use]
pub const fn start_state(&self) -> StartState {
match self {
Self::StartedRunning | Self::WaitingStreamed => StartState::Started,
Self::QueuedNotStarted
| Self::TimedOutQueued
| Self::CancelledBeforeStart
| Self::FailedBeforeAdmission { .. } => StartState::NotStarted,
}
}
#[must_use]
pub const fn started(&self) -> bool {
matches!(self.start_state(), StartState::Started)
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::StartedRunning => "started_running",
Self::WaitingStreamed => "waiting_streamed",
Self::QueuedNotStarted => "queued_not_started",
Self::TimedOutQueued => "timed_out_queued",
Self::CancelledBeforeStart => "cancelled_before_start",
Self::FailedBeforeAdmission { .. } => "failed_before_admission",
}
}
}
#[must_use]
pub fn resolve_queue_contract(
admission: &AdmissionState,
opts: &QueueOptions,
wait: Option<WaitResult>,
) -> QueueContractOutcome {
match admission {
AdmissionState::RejectedBeforeAdmission(reason) => {
QueueContractOutcome::FailedBeforeAdmission {
reason: reason.clone(),
}
}
AdmissionState::AdmittedRunning => QueueContractOutcome::StartedRunning,
AdmissionState::Queued => {
if opts.strict {
return QueueContractOutcome::FailedBeforeAdmission {
reason: "strict mode requires immediate admission; refusing to queue"
.to_string(),
};
}
if opts.no_start || !(opts.wait || opts.follow) {
return QueueContractOutcome::QueuedNotStarted;
}
match wait {
Some(WaitResult::StartedAfterWait) => QueueContractOutcome::WaitingStreamed,
Some(WaitResult::TimedOut) => QueueContractOutcome::TimedOutQueued,
Some(WaitResult::CancelledBeforeStart) => {
QueueContractOutcome::CancelledBeforeStart
}
None => QueueContractOutcome::QueuedNotStarted,
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QueueContractResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
pub started: bool,
pub outcome: String,
pub reattachable: bool,
pub detail: String,
}
impl QueueContractResponse {
#[must_use]
pub fn build(outcome: &QueueContractOutcome, job_id: Option<String>) -> Self {
let started = outcome.started();
let has_job = job_id.is_some();
let reattachable = has_job && !started;
let detail = match outcome {
QueueContractOutcome::StartedRunning => "admitted and running".to_string(),
QueueContractOutcome::WaitingStreamed => "queued, then started; streaming".to_string(),
QueueContractOutcome::QueuedNotStarted => {
"queued; not started — reattach with the job id".to_string()
}
QueueContractOutcome::TimedOutQueued => {
"wait timed out while queued; not started — reattach with the job id".to_string()
}
QueueContractOutcome::CancelledBeforeStart => "cancelled before it started".to_string(),
QueueContractOutcome::FailedBeforeAdmission { reason } => {
format!("failed before admission: {reason}")
}
};
Self {
job_id,
started,
outcome: outcome.as_str().to_string(),
reattachable,
detail,
}
}
#[must_use]
pub fn render(&self) -> String {
format!(
"queue: {} (started={}, reattachable={}{}) — {}",
self.outcome,
self.started,
self.reattachable,
self.job_id
.as_deref()
.map_or_else(String::new, |j| format!(", job={j}")),
self.detail,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn admitted_running_starts() {
let o = resolve_queue_contract(
&AdmissionState::AdmittedRunning,
&QueueOptions::default(),
None,
);
assert_eq!(o, QueueContractOutcome::StartedRunning);
assert!(o.started());
assert_eq!(o.start_state(), StartState::Started);
}
#[test]
fn busy_workers_no_wait_returns_reattachable_not_started() {
let o = resolve_queue_contract(&AdmissionState::Queued, &QueueOptions::default(), None);
assert_eq!(o, QueueContractOutcome::QueuedNotStarted);
assert!(!o.started());
let resp = QueueContractResponse::build(&o, Some("job-1".to_string()));
assert!(!resp.started);
assert!(resp.reattachable);
assert_eq!(resp.job_id.as_deref(), Some("job-1"));
}
#[test]
fn no_start_option_returns_job_id_not_started() {
let opts = QueueOptions {
no_start: true,
..QueueOptions::default()
};
let o = resolve_queue_contract(&AdmissionState::Queued, &opts, None);
assert_eq!(o, QueueContractOutcome::QueuedNotStarted);
}
#[test]
fn wait_then_started_streams() {
let opts = QueueOptions {
wait: true,
follow: true,
..QueueOptions::default()
};
let o = resolve_queue_contract(
&AdmissionState::Queued,
&opts,
Some(WaitResult::StartedAfterWait),
);
assert_eq!(o, QueueContractOutcome::WaitingStreamed);
assert!(o.started());
}
#[test]
fn queue_timeout_returns_reattachable_not_started() {
let opts = QueueOptions {
wait: true,
wait_timeout_secs: Some(30),
..QueueOptions::default()
};
let o = resolve_queue_contract(&AdmissionState::Queued, &opts, Some(WaitResult::TimedOut));
assert_eq!(o, QueueContractOutcome::TimedOutQueued);
assert!(!o.started());
let resp = QueueContractResponse::build(&o, Some("job-7".to_string()));
assert!(resp.reattachable);
assert!(resp.detail.contains("reattach"));
}
#[test]
fn cancellation_before_start() {
let opts = QueueOptions {
wait: true,
..QueueOptions::default()
};
let o = resolve_queue_contract(
&AdmissionState::Queued,
&opts,
Some(WaitResult::CancelledBeforeStart),
);
assert_eq!(o, QueueContractOutcome::CancelledBeforeStart);
assert!(!o.started());
}
#[test]
fn strict_mode_refuses_to_queue() {
let opts = QueueOptions {
strict: true,
wait: true,
..QueueOptions::default()
};
let o = resolve_queue_contract(&AdmissionState::Queued, &opts, None);
assert!(matches!(
o,
QueueContractOutcome::FailedBeforeAdmission { .. }
));
assert!(!o.started());
}
#[test]
fn rejected_before_admission_never_starts_and_has_no_job() {
let o = resolve_queue_contract(
&AdmissionState::RejectedBeforeAdmission("no admissible workers".to_string()),
&QueueOptions::default(),
None,
);
assert_eq!(o.as_str(), "failed_before_admission");
let resp = QueueContractResponse::build(&o, None);
assert!(!resp.started);
assert!(!resp.reattachable, "no job => not reattachable");
assert!(resp.job_id.is_none());
assert!(resp.detail.contains("no admissible workers"));
}
#[test]
fn every_outcome_has_a_definite_start_state() {
let outcomes = [
QueueContractOutcome::StartedRunning,
QueueContractOutcome::WaitingStreamed,
QueueContractOutcome::QueuedNotStarted,
QueueContractOutcome::TimedOutQueued,
QueueContractOutcome::CancelledBeforeStart,
QueueContractOutcome::FailedBeforeAdmission {
reason: "x".to_string(),
},
];
for o in &outcomes {
let s = o.start_state();
assert!(s == StartState::Started || s == StartState::NotStarted);
assert_eq!(o.started(), s == StartState::Started);
}
}
#[test]
fn response_serde_and_render() {
let o = resolve_queue_contract(&AdmissionState::Queued, &QueueOptions::default(), None);
let resp = QueueContractResponse::build(&o, Some("job-9".to_string()));
let v = serde_json::to_value(&resp).unwrap();
assert_eq!(v["outcome"], "queued_not_started");
assert_eq!(v["started"], false);
assert_eq!(v["reattachable"], true);
let back: QueueContractResponse = serde_json::from_value(v).unwrap();
assert_eq!(resp, back);
assert!(resp.render().contains("job=job-9"));
}
}