1use std::collections::VecDeque;
15use std::sync::{Arc, Condvar, Mutex};
16use std::thread::{self, JoinHandle};
17
18use super::jobs::{now_ms, MlJob, MlJobId, MlJobKind, MlJobStatus};
19use super::persist::{key, ns, MlPersistence};
20
21pub type MlWorkFn = Arc<dyn Fn(JobHandle) -> Result<String, String> + Send + Sync>;
28
29#[derive(Clone)]
33pub struct JobHandle {
34 id: MlJobId,
35 shared: Arc<Shared>,
36}
37
38impl JobHandle {
39 pub fn id(&self) -> MlJobId {
40 self.id
41 }
42
43 pub fn set_progress(&self, progress: u8) {
47 let snapshot = {
48 let mut guard = match self.shared.state.lock() {
49 Ok(g) => g,
50 Err(p) => p.into_inner(),
51 };
52 if let Some(job) = find_job_mut(&mut guard.jobs, self.id) {
53 if !job.is_terminal() {
54 job.progress = progress.min(100);
55 Some(job.clone())
56 } else {
57 None
58 }
59 } else {
60 None
61 }
62 };
63 if let Some(job) = snapshot {
64 persist_job(&self.shared, &job);
65 }
66 }
67
68 pub fn is_cancelled(&self) -> bool {
72 let guard = match self.shared.state.lock() {
73 Ok(g) => g,
74 Err(p) => p.into_inner(),
75 };
76 guard
77 .jobs
78 .iter()
79 .find(|j| j.id == self.id)
80 .map(|j| j.status == MlJobStatus::Cancelled)
81 .unwrap_or(false)
82 }
83}
84
85struct Shared {
86 state: Mutex<QueueState>,
87 signal: Condvar,
88 persistence: Mutex<()>,
90 backend: Option<Arc<dyn MlPersistence>>,
91}
92
93impl std::fmt::Debug for Shared {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct("Shared")
96 .field("has_backend", &self.backend.is_some())
97 .finish()
98 }
99}
100
101fn persist_job(shared: &Arc<Shared>, job: &MlJob) {
102 let Some(backend) = shared.backend.as_ref() else {
103 return;
104 };
105 let _persistence = shared.persistence.lock().unwrap_or_else(|p| p.into_inner());
110 let current = {
111 let state = shared.state.lock().unwrap_or_else(|p| p.into_inner());
112 state
113 .jobs
114 .iter()
115 .find(|current| current.id == job.id)
116 .cloned()
117 };
118 if let Some(current) = current {
119 let raw = current.to_json();
120 let _ = backend.put(ns::JOBS, &key::job(current.id), &raw);
121 }
122}
123
124#[derive(Debug)]
125struct QueueState {
126 pending: VecDeque<MlJobId>,
128 jobs: Vec<MlJob>,
131 shutting_down: bool,
133 next_id: u128,
136}
137
138#[derive(Clone)]
141pub struct MlJobQueue {
142 shared: Arc<Shared>,
143 worker_fn: MlWorkFn,
144 workers: Arc<Mutex<Vec<JoinHandle<()>>>>,
145}
146
147impl std::fmt::Debug for MlJobQueue {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.debug_struct("MlJobQueue")
150 .field(
151 "worker_count",
152 &self.workers.lock().map(|w| w.len()).unwrap_or(0),
153 )
154 .finish()
155 }
156}
157
158impl MlJobQueue {
159 pub fn start(worker_count: usize, worker_fn: MlWorkFn) -> Self {
163 Self::start_with(worker_count, worker_fn, None)
164 }
165
166 pub fn start_with_backend(
171 worker_count: usize,
172 worker_fn: MlWorkFn,
173 backend: Arc<dyn MlPersistence>,
174 ) -> Self {
175 Self::start_with(worker_count, worker_fn, Some(backend))
176 }
177
178 fn start_with(
179 worker_count: usize,
180 worker_fn: MlWorkFn,
181 backend: Option<Arc<dyn MlPersistence>>,
182 ) -> Self {
183 let mut initial_jobs: Vec<MlJob> = Vec::new();
186 let mut initial_pending: VecDeque<MlJobId> = VecDeque::new();
187 let mut resume_next_id: u128 = 1;
188 if let Some(be) = backend.as_ref() {
189 if let Ok(rows) = be.list(ns::JOBS) {
190 for (_, raw) in rows {
191 let Some(mut job) = MlJob::from_json(&raw) else {
192 continue;
193 };
194 if job.status == MlJobStatus::Running {
199 job.status = MlJobStatus::Queued;
200 job.progress = 0;
201 job.started_at_ms = 0;
202 }
203 if job.status == MlJobStatus::Queued {
204 initial_pending.push_back(job.id);
205 }
206 resume_next_id = resume_next_id.max(job.id.saturating_add(1));
207 initial_jobs.push(job);
208 }
209 }
210 }
211
212 let shared = Arc::new(Shared {
213 state: Mutex::new(QueueState {
214 pending: initial_pending,
215 jobs: initial_jobs.clone(),
216 shutting_down: false,
217 next_id: resume_next_id,
218 }),
219 signal: Condvar::new(),
220 persistence: Mutex::new(()),
221 backend,
222 });
223
224 for job in &initial_jobs {
227 if job.status == MlJobStatus::Queued {
228 persist_job(&shared, job);
229 }
230 }
231
232 let workers = Arc::new(Mutex::new(Vec::with_capacity(worker_count.max(1))));
233 let queue = MlJobQueue {
234 shared: Arc::clone(&shared),
235 worker_fn: Arc::clone(&worker_fn),
236 workers: Arc::clone(&workers),
237 };
238 for _ in 0..worker_count.max(1) {
239 let shared_w = Arc::clone(&shared);
240 let worker_fn_w = Arc::clone(&worker_fn);
241 let handle = thread::spawn(move || worker_loop(shared_w, worker_fn_w));
242 if let Ok(mut guard) = workers.lock() {
243 guard.push(handle);
244 }
245 }
246 queue
247 }
248
249 pub fn submit(
252 &self,
253 kind: MlJobKind,
254 target_name: impl Into<String>,
255 spec_json: impl Into<String>,
256 ) -> MlJobId {
257 let snapshot = {
258 let mut guard = match self.shared.state.lock() {
259 Ok(g) => g,
260 Err(p) => p.into_inner(),
261 };
262 let id = guard.next_id;
263 guard.next_id = guard.next_id.saturating_add(1);
264 let job = MlJob::new(id, kind, target_name.into(), spec_json.into());
265 let snapshot = job.clone();
266 guard.jobs.push(job);
267 guard.pending.push_back(id);
268 snapshot
269 };
270 persist_job(&self.shared, &snapshot);
271 self.shared.signal.notify_one();
272 snapshot.id
273 }
274
275 pub fn get(&self, id: MlJobId) -> Option<MlJob> {
277 let guard = match self.shared.state.lock() {
278 Ok(g) => g,
279 Err(p) => p.into_inner(),
280 };
281 guard.jobs.iter().find(|j| j.id == id).cloned()
282 }
283
284 pub fn list(&self) -> Vec<MlJob> {
287 let guard = match self.shared.state.lock() {
288 Ok(g) => g,
289 Err(p) => p.into_inner(),
290 };
291 guard.jobs.clone()
292 }
293
294 pub fn cancel(&self, id: MlJobId) -> bool {
298 let snapshot = {
299 let mut guard = match self.shared.state.lock() {
300 Ok(g) => g,
301 Err(p) => p.into_inner(),
302 };
303 let Some(job) = find_job_mut(&mut guard.jobs, id) else {
304 return false;
305 };
306 if job.is_terminal() {
307 return false;
308 }
309 let was_queued = job.status == MlJobStatus::Queued;
310 job.status = MlJobStatus::Cancelled;
311 job.finished_at_ms = now_ms();
312 let snapshot = job.clone();
313 if was_queued {
314 guard.pending.retain(|pid| *pid != id);
317 }
318 snapshot
319 };
320 persist_job(&self.shared, &snapshot);
321 true
322 }
323
324 pub fn shutdown(&self) {
327 {
328 let mut guard = match self.shared.state.lock() {
329 Ok(g) => g,
330 Err(p) => p.into_inner(),
331 };
332 guard.shutting_down = true;
333 }
334 self.shared.signal.notify_all();
335 let Ok(mut workers) = self.workers.lock() else {
336 return;
337 };
338 for handle in workers.drain(..) {
339 let _ = handle.join();
340 }
341 }
342}
343
344fn find_job_mut(jobs: &mut [MlJob], id: MlJobId) -> Option<&mut MlJob> {
345 jobs.iter_mut().find(|j| j.id == id)
346}
347
348fn worker_loop(shared: Arc<Shared>, worker_fn: MlWorkFn) {
349 loop {
350 let (next_id, running_snapshot) = {
353 let guard = match shared.state.lock() {
354 Ok(g) => g,
355 Err(p) => p.into_inner(),
356 };
357 let mut guard = match shared
358 .signal
359 .wait_while(guard, |s| s.pending.is_empty() && !s.shutting_down)
360 {
361 Ok(g) => g,
362 Err(p) => p.into_inner(),
363 };
364 if guard.shutting_down && guard.pending.is_empty() {
365 return;
366 }
367 let id = match guard.pending.pop_front() {
368 Some(id) => id,
369 None => continue,
370 };
371 let mut snapshot = None;
372 if let Some(job) = find_job_mut(&mut guard.jobs, id) {
373 if job.status == MlJobStatus::Cancelled {
376 continue;
377 }
378 job.status = MlJobStatus::Running;
379 job.started_at_ms = now_ms();
380 snapshot = Some(job.clone());
381 }
382 (id, snapshot)
383 };
384 if let Some(job) = running_snapshot {
385 persist_job(&shared, &job);
386 }
387
388 let handle = JobHandle {
389 id: next_id,
390 shared: Arc::clone(&shared),
391 };
392 let outcome = (worker_fn)(handle);
393
394 let post_snapshot = {
395 let mut guard = match shared.state.lock() {
396 Ok(g) => g,
397 Err(p) => p.into_inner(),
398 };
399 if let Some(job) = find_job_mut(&mut guard.jobs, next_id) {
400 if job.status == MlJobStatus::Cancelled {
403 if job.finished_at_ms == 0 {
404 job.finished_at_ms = now_ms();
405 }
406 Some(job.clone())
407 } else {
408 match outcome {
409 Ok(metrics) => {
410 job.status = MlJobStatus::Completed;
411 job.progress = 100;
412 job.metrics_json = Some(metrics);
413 }
414 Err(err) => {
415 job.status = MlJobStatus::Failed;
416 job.error_message = Some(err);
417 }
418 }
419 job.finished_at_ms = now_ms();
420 Some(job.clone())
421 }
422 } else {
423 None
424 }
425 };
426 if let Some(job) = post_snapshot {
427 persist_job(&shared, &job);
428 }
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use std::sync::atomic::{AtomicUsize, Ordering};
436 use std::time::{Duration, Instant};
437
438 fn wait_until<F: Fn() -> bool>(predicate: F, timeout: Duration) -> bool {
439 let deadline = Instant::now() + timeout;
440 while Instant::now() < deadline {
441 if predicate() {
442 return true;
443 }
444 thread::sleep(Duration::from_millis(5));
445 }
446 predicate()
447 }
448
449 #[test]
450 fn submit_and_run_to_completion() {
451 let counter = Arc::new(AtomicUsize::new(0));
452 let counter_w = Arc::clone(&counter);
453 let q = MlJobQueue::start(
454 1,
455 Arc::new(move |handle| {
456 counter_w.fetch_add(1, Ordering::SeqCst);
457 handle.set_progress(50);
458 handle.set_progress(100);
459 Ok("{\"ok\":true}".to_string())
460 }),
461 );
462 let id = q.submit(MlJobKind::Train, "spam", "{}");
463 assert!(wait_until(
464 || q.get(id).map(|j| j.is_terminal()).unwrap_or(false),
465 Duration::from_secs(2),
466 ));
467 let job = q.get(id).unwrap();
468 assert_eq!(job.status, MlJobStatus::Completed);
469 assert_eq!(job.progress, 100);
470 assert_eq!(job.metrics_json.as_deref(), Some("{\"ok\":true}"));
471 assert_eq!(counter.load(Ordering::SeqCst), 1);
472 q.shutdown();
473 }
474
475 #[test]
476 fn failed_work_records_error() {
477 let q = MlJobQueue::start(1, Arc::new(|_| Err("bad hyperparameters".to_string())));
478 let id = q.submit(MlJobKind::Train, "spam", "{}");
479 assert!(wait_until(
480 || q.get(id).map(|j| j.is_terminal()).unwrap_or(false),
481 Duration::from_secs(2),
482 ));
483 let job = q.get(id).unwrap();
484 assert_eq!(job.status, MlJobStatus::Failed);
485 assert_eq!(job.error_message.as_deref(), Some("bad hyperparameters"));
486 q.shutdown();
487 }
488
489 #[test]
490 fn cancel_while_queued_prevents_execution() {
491 let ran = Arc::new(AtomicUsize::new(0));
492 let ran_w = Arc::clone(&ran);
493 let q = MlJobQueue::start(
495 1,
496 Arc::new(move |handle| {
497 if handle.id() == 1 {
498 thread::sleep(Duration::from_millis(100));
500 } else {
501 ran_w.fetch_add(1, Ordering::SeqCst);
502 }
503 Ok("{}".to_string())
504 }),
505 );
506 let _first = q.submit(MlJobKind::Train, "a", "{}");
507 let second = q.submit(MlJobKind::Train, "b", "{}");
508 assert!(q.cancel(second));
509 thread::sleep(Duration::from_millis(250));
510 let job = q.get(second).unwrap();
511 assert_eq!(job.status, MlJobStatus::Cancelled);
512 assert_eq!(ran.load(Ordering::SeqCst), 0, "cancelled job must not run");
513 q.shutdown();
514 }
515
516 #[test]
517 fn cancel_after_terminal_is_noop() {
518 let q = MlJobQueue::start(1, Arc::new(|_| Ok("{}".to_string())));
519 let id = q.submit(MlJobKind::Train, "x", "{}");
520 assert!(wait_until(
521 || q.get(id).map(|j| j.is_terminal()).unwrap_or(false),
522 Duration::from_secs(2),
523 ));
524 assert!(!q.cancel(id));
525 q.shutdown();
526 }
527
528 #[test]
529 fn cooperative_cancellation_observed_by_worker() {
530 let observed = Arc::new(AtomicUsize::new(0));
536 let iters = Arc::new(AtomicUsize::new(0));
537 let observed_w = Arc::clone(&observed);
538 let iters_w = Arc::clone(&iters);
539 let q = MlJobQueue::start(
540 1,
541 Arc::new(move |handle| {
542 for _ in 0..200 {
543 iters_w.fetch_add(1, Ordering::SeqCst);
544 if handle.is_cancelled() {
545 observed_w.fetch_add(1, Ordering::SeqCst);
546 return Err("cancelled".to_string());
547 }
548 handle.set_progress(10);
549 thread::sleep(Duration::from_millis(5));
550 }
551 Ok("{}".to_string())
552 }),
553 );
554 let id = q.submit(MlJobKind::Train, "slow", "{}");
555 assert!(wait_until(
556 || iters.load(Ordering::SeqCst) > 0,
557 Duration::from_secs(2),
558 ));
559 assert!(q.cancel(id));
560 assert!(wait_until(
561 || observed.load(Ordering::SeqCst) >= 1,
562 Duration::from_secs(2),
563 ));
564 let job = q.get(id).unwrap();
565 assert_eq!(job.status, MlJobStatus::Cancelled);
566 q.shutdown();
567 }
568
569 #[test]
570 fn backend_persists_submit_and_completion() {
571 use super::super::persist::InMemoryMlPersistence;
572 let backend = Arc::new(InMemoryMlPersistence::new());
573 let q = MlJobQueue::start_with_backend(
574 1,
575 Arc::new(|_| Ok("{\"f1\":0.9}".to_string())),
576 backend.clone(),
577 );
578 let id = q.submit(MlJobKind::Train, "spam", "{}");
579 q.shutdown();
580
581 let raw = backend
583 .get(super::ns::JOBS, &super::key::job(id))
584 .unwrap()
585 .unwrap();
586 let decoded = MlJob::from_json(&raw).unwrap();
587 assert_eq!(decoded.status, MlJobStatus::Completed);
588 assert_eq!(decoded.metrics_json.as_deref(), Some("{\"f1\":0.9}"));
589 }
590
591 #[test]
592 fn delayed_queued_snapshot_cannot_replace_a_completed_job() {
593 use super::super::persist::InMemoryMlPersistence;
594 let backend = Arc::new(InMemoryMlPersistence::new());
595 let q = MlJobQueue::start_with_backend(
596 1,
597 Arc::new(|_| Ok("{\"done\":true}".to_string())),
598 backend.clone(),
599 );
600 let id = q.submit(MlJobKind::Train, "late", "{}");
601 let queued = MlJob::new(id, MlJobKind::Train, "late".into(), "{}".into());
602 q.shutdown();
603 assert_eq!(q.get(id).unwrap().status, MlJobStatus::Completed);
604
605 persist_job(&q.shared, &queued);
608 let raw = backend
609 .get(super::ns::JOBS, &super::key::job(id))
610 .unwrap()
611 .unwrap();
612 let restored = MlJob::from_json(&raw).unwrap();
613 assert_eq!(restored.status, MlJobStatus::Completed);
614 assert_eq!(restored.metrics_json.as_deref(), Some("{\"done\":true}"));
615 }
616
617 #[test]
618 fn resume_from_backend_requeues_running_jobs() {
619 use super::super::persist::InMemoryMlPersistence;
620 let backend: Arc<dyn super::MlPersistence> = Arc::new(InMemoryMlPersistence::new());
621
622 let pending = MlJob {
625 id: 5,
626 kind: MlJobKind::Train,
627 target_name: "a".into(),
628 status: MlJobStatus::Queued,
629 progress: 0,
630 created_at_ms: 1,
631 started_at_ms: 0,
632 finished_at_ms: 0,
633 error_message: None,
634 spec_json: "{}".into(),
635 metrics_json: None,
636 };
637 let stuck = MlJob {
638 id: 6,
639 kind: MlJobKind::Train,
640 target_name: "b".into(),
641 status: MlJobStatus::Running,
642 progress: 40,
643 created_at_ms: 2,
644 started_at_ms: 3,
645 finished_at_ms: 0,
646 error_message: None,
647 spec_json: "{}".into(),
648 metrics_json: None,
649 };
650 let done = MlJob {
651 id: 7,
652 kind: MlJobKind::Train,
653 target_name: "c".into(),
654 status: MlJobStatus::Completed,
655 progress: 100,
656 created_at_ms: 3,
657 started_at_ms: 4,
658 finished_at_ms: 5,
659 error_message: None,
660 spec_json: "{}".into(),
661 metrics_json: Some("{}".into()),
662 };
663 for j in [&pending, &stuck, &done] {
664 backend
665 .put(super::ns::JOBS, &super::key::job(j.id), &j.to_json())
666 .unwrap();
667 }
668
669 let ran = Arc::new(AtomicUsize::new(0));
670 let ran_w = Arc::clone(&ran);
671 let q = MlJobQueue::start_with_backend(
672 2,
673 Arc::new(move |_| {
674 ran_w.fetch_add(1, Ordering::SeqCst);
675 Ok("{}".to_string())
676 }),
677 backend.clone(),
678 );
679
680 assert!(wait_until(
681 || ran.load(Ordering::SeqCst) >= 2,
682 Duration::from_secs(3),
683 ));
684 assert_eq!(q.get(5).unwrap().status, MlJobStatus::Completed);
687 assert_eq!(q.get(6).unwrap().status, MlJobStatus::Completed);
688 assert_eq!(q.get(7).unwrap().status, MlJobStatus::Completed);
689
690 let fresh_id = q.submit(MlJobKind::Train, "d", "{}");
692 assert!(fresh_id > 7);
693
694 q.shutdown();
695 }
696
697 #[test]
698 fn multiple_workers_drain_backlog() {
699 let q = MlJobQueue::start(
700 3,
701 Arc::new(|handle| {
702 handle.set_progress(50);
703 thread::sleep(Duration::from_millis(20));
704 Ok("{}".to_string())
705 }),
706 );
707 let ids: Vec<_> = (0..20)
708 .map(|i| q.submit(MlJobKind::Train, format!("m{i}"), "{}"))
709 .collect();
710 assert!(wait_until(
711 || ids
712 .iter()
713 .all(|id| q.get(*id).map(|j| j.is_terminal()).unwrap_or(false)),
714 Duration::from_secs(5),
715 ));
716 for id in ids {
717 assert_eq!(q.get(id).unwrap().status, MlJobStatus::Completed);
718 }
719 q.shutdown();
720 }
721}