1use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
17pub struct QueueOptions {
18 pub wait: bool,
20 pub follow: bool,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub wait_timeout_secs: Option<u64>,
25 pub no_start: bool,
27 pub strict: bool,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum AdmissionState {
34 AdmittedRunning,
36 Queued,
38 RejectedBeforeAdmission(String),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum WaitResult {
45 StartedAfterWait,
47 TimedOut,
49 CancelledBeforeStart,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum StartState {
58 Started,
59 NotStarted,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case", tag = "outcome")]
65pub enum QueueContractOutcome {
66 StartedRunning,
68 WaitingStreamed,
70 QueuedNotStarted,
72 TimedOutQueued,
74 CancelledBeforeStart,
76 FailedBeforeAdmission { reason: String },
78}
79
80impl QueueContractOutcome {
81 #[must_use]
83 pub const fn start_state(&self) -> StartState {
84 match self {
85 Self::StartedRunning | Self::WaitingStreamed => StartState::Started,
86 Self::QueuedNotStarted
87 | Self::TimedOutQueued
88 | Self::CancelledBeforeStart
89 | Self::FailedBeforeAdmission { .. } => StartState::NotStarted,
90 }
91 }
92
93 #[must_use]
95 pub const fn started(&self) -> bool {
96 matches!(self.start_state(), StartState::Started)
97 }
98
99 #[must_use]
101 pub const fn as_str(&self) -> &'static str {
102 match self {
103 Self::StartedRunning => "started_running",
104 Self::WaitingStreamed => "waiting_streamed",
105 Self::QueuedNotStarted => "queued_not_started",
106 Self::TimedOutQueued => "timed_out_queued",
107 Self::CancelledBeforeStart => "cancelled_before_start",
108 Self::FailedBeforeAdmission { .. } => "failed_before_admission",
109 }
110 }
111}
112
113#[must_use]
117pub fn resolve_queue_contract(
118 admission: &AdmissionState,
119 opts: &QueueOptions,
120 wait: Option<WaitResult>,
121) -> QueueContractOutcome {
122 match admission {
123 AdmissionState::RejectedBeforeAdmission(reason) => {
124 QueueContractOutcome::FailedBeforeAdmission {
125 reason: reason.clone(),
126 }
127 }
128 AdmissionState::AdmittedRunning => QueueContractOutcome::StartedRunning,
129 AdmissionState::Queued => {
130 if opts.strict {
132 return QueueContractOutcome::FailedBeforeAdmission {
133 reason: "strict mode requires immediate admission; refusing to queue"
134 .to_string(),
135 };
136 }
137 if opts.no_start || !(opts.wait || opts.follow) {
139 return QueueContractOutcome::QueuedNotStarted;
140 }
141 match wait {
142 Some(WaitResult::StartedAfterWait) => QueueContractOutcome::WaitingStreamed,
143 Some(WaitResult::TimedOut) => QueueContractOutcome::TimedOutQueued,
144 Some(WaitResult::CancelledBeforeStart) => {
145 QueueContractOutcome::CancelledBeforeStart
146 }
147 None => QueueContractOutcome::QueuedNotStarted,
148 }
149 }
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct QueueContractResponse {
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub job_id: Option<String>,
159 pub started: bool,
161 pub outcome: String,
163 pub reattachable: bool,
165 pub detail: String,
167}
168
169impl QueueContractResponse {
170 #[must_use]
172 pub fn build(outcome: &QueueContractOutcome, job_id: Option<String>) -> Self {
173 let started = outcome.started();
174 let has_job = job_id.is_some();
177 let reattachable = has_job && !started;
178 let detail = match outcome {
179 QueueContractOutcome::StartedRunning => "admitted and running".to_string(),
180 QueueContractOutcome::WaitingStreamed => "queued, then started; streaming".to_string(),
181 QueueContractOutcome::QueuedNotStarted => {
182 "queued; not started — reattach with the job id".to_string()
183 }
184 QueueContractOutcome::TimedOutQueued => {
185 "wait timed out while queued; not started — reattach with the job id".to_string()
186 }
187 QueueContractOutcome::CancelledBeforeStart => "cancelled before it started".to_string(),
188 QueueContractOutcome::FailedBeforeAdmission { reason } => {
189 format!("failed before admission: {reason}")
190 }
191 };
192 Self {
193 job_id,
194 started,
195 outcome: outcome.as_str().to_string(),
196 reattachable,
197 detail,
198 }
199 }
200
201 #[must_use]
203 pub fn render(&self) -> String {
204 format!(
205 "queue: {} (started={}, reattachable={}{}) — {}",
206 self.outcome,
207 self.started,
208 self.reattachable,
209 self.job_id
210 .as_deref()
211 .map_or_else(String::new, |j| format!(", job={j}")),
212 self.detail,
213 )
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn admitted_running_starts() {
223 let o = resolve_queue_contract(
224 &AdmissionState::AdmittedRunning,
225 &QueueOptions::default(),
226 None,
227 );
228 assert_eq!(o, QueueContractOutcome::StartedRunning);
229 assert!(o.started());
230 assert_eq!(o.start_state(), StartState::Started);
231 }
232
233 #[test]
234 fn busy_workers_no_wait_returns_reattachable_not_started() {
235 let o = resolve_queue_contract(&AdmissionState::Queued, &QueueOptions::default(), None);
237 assert_eq!(o, QueueContractOutcome::QueuedNotStarted);
238 assert!(!o.started());
239 let resp = QueueContractResponse::build(&o, Some("job-1".to_string()));
240 assert!(!resp.started);
241 assert!(resp.reattachable);
242 assert_eq!(resp.job_id.as_deref(), Some("job-1"));
243 }
244
245 #[test]
246 fn no_start_option_returns_job_id_not_started() {
247 let opts = QueueOptions {
248 no_start: true,
249 ..QueueOptions::default()
250 };
251 let o = resolve_queue_contract(&AdmissionState::Queued, &opts, None);
252 assert_eq!(o, QueueContractOutcome::QueuedNotStarted);
253 }
254
255 #[test]
256 fn wait_then_started_streams() {
257 let opts = QueueOptions {
258 wait: true,
259 follow: true,
260 ..QueueOptions::default()
261 };
262 let o = resolve_queue_contract(
263 &AdmissionState::Queued,
264 &opts,
265 Some(WaitResult::StartedAfterWait),
266 );
267 assert_eq!(o, QueueContractOutcome::WaitingStreamed);
268 assert!(o.started());
269 }
270
271 #[test]
272 fn queue_timeout_returns_reattachable_not_started() {
273 let opts = QueueOptions {
274 wait: true,
275 wait_timeout_secs: Some(30),
276 ..QueueOptions::default()
277 };
278 let o = resolve_queue_contract(&AdmissionState::Queued, &opts, Some(WaitResult::TimedOut));
279 assert_eq!(o, QueueContractOutcome::TimedOutQueued);
280 assert!(!o.started());
281 let resp = QueueContractResponse::build(&o, Some("job-7".to_string()));
282 assert!(resp.reattachable);
283 assert!(resp.detail.contains("reattach"));
284 }
285
286 #[test]
287 fn cancellation_before_start() {
288 let opts = QueueOptions {
289 wait: true,
290 ..QueueOptions::default()
291 };
292 let o = resolve_queue_contract(
293 &AdmissionState::Queued,
294 &opts,
295 Some(WaitResult::CancelledBeforeStart),
296 );
297 assert_eq!(o, QueueContractOutcome::CancelledBeforeStart);
298 assert!(!o.started());
299 }
300
301 #[test]
302 fn strict_mode_refuses_to_queue() {
303 let opts = QueueOptions {
304 strict: true,
305 wait: true,
306 ..QueueOptions::default()
307 };
308 let o = resolve_queue_contract(&AdmissionState::Queued, &opts, None);
309 assert!(matches!(
310 o,
311 QueueContractOutcome::FailedBeforeAdmission { .. }
312 ));
313 assert!(!o.started());
314 }
315
316 #[test]
317 fn rejected_before_admission_never_starts_and_has_no_job() {
318 let o = resolve_queue_contract(
319 &AdmissionState::RejectedBeforeAdmission("no admissible workers".to_string()),
320 &QueueOptions::default(),
321 None,
322 );
323 assert_eq!(o.as_str(), "failed_before_admission");
324 let resp = QueueContractResponse::build(&o, None);
325 assert!(!resp.started);
326 assert!(!resp.reattachable, "no job => not reattachable");
327 assert!(resp.job_id.is_none());
328 assert!(resp.detail.contains("no admissible workers"));
329 }
330
331 #[test]
332 fn every_outcome_has_a_definite_start_state() {
333 let outcomes = [
335 QueueContractOutcome::StartedRunning,
336 QueueContractOutcome::WaitingStreamed,
337 QueueContractOutcome::QueuedNotStarted,
338 QueueContractOutcome::TimedOutQueued,
339 QueueContractOutcome::CancelledBeforeStart,
340 QueueContractOutcome::FailedBeforeAdmission {
341 reason: "x".to_string(),
342 },
343 ];
344 for o in &outcomes {
345 let s = o.start_state();
347 assert!(s == StartState::Started || s == StartState::NotStarted);
348 assert_eq!(o.started(), s == StartState::Started);
349 }
350 }
351
352 #[test]
353 fn response_serde_and_render() {
354 let o = resolve_queue_contract(&AdmissionState::Queued, &QueueOptions::default(), None);
355 let resp = QueueContractResponse::build(&o, Some("job-9".to_string()));
356 let v = serde_json::to_value(&resp).unwrap();
357 assert_eq!(v["outcome"], "queued_not_started");
358 assert_eq!(v["started"], false);
359 assert_eq!(v["reattachable"], true);
360 let back: QueueContractResponse = serde_json::from_value(v).unwrap();
361 assert_eq!(resp, back);
362 assert!(resp.render().contains("job=job-9"));
363 }
364}