Skip to main content

reddb_server/storage/ml/
queue.rs

1//! Async ML job queue — FIFO queue + worker pool + job-state registry.
2//!
3//! Callers `submit()` a job and get back an [`MlJobId`] immediately.
4//! Worker threads pick jobs off the queue, invoke the caller-supplied
5//! [`MlWorkFn`] to perform the actual work, and record progress +
6//! terminal status back into the queue's job table.
7//!
8//! Cancellation is cooperative: setting a job to [`MlJobStatus::Cancelled`]
9//! flips a flag that the worker polls between checkpoints. Workers
10//! that never check the flag cannot be forcibly killed — this is a
11//! deliberate trade-off (no unsafe thread termination, clean up is
12//! the algorithm's responsibility).
13
14use 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
21/// Callback invoked on a worker thread to perform the actual work.
22///
23/// The closure receives a [`JobHandle`] it uses to update progress
24/// and to check cancellation. It returns `Ok(metrics_json)` on
25/// success (which will be stored on the job record) or `Err(msg)` on
26/// failure (surfaced as `error_message`).
27pub type MlWorkFn = Arc<dyn Fn(JobHandle) -> Result<String, String> + Send + Sync>;
28
29/// Handle passed into an [`MlWorkFn`]. The worker uses it to report
30/// progress and observe cancellation — no other mutations are
31/// possible, which keeps contracts small.
32#[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    /// Update the `progress` field (0..=100). Values > 100 are
44    /// clamped. Non-monotonic updates are allowed — workers that
45    /// retry a checkpoint can move progress backwards.
46    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    /// True when the operator has requested cancellation. Workers
69    /// should poll this at safe boundaries (per batch / per
70    /// generation) and return promptly on a positive.
71    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    // Serialize backend writes independently of the state lock.
89    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    // A transition can be superseded before its persistence call runs.
106    // Serialize writes and read the latest state while holding that ordering
107    // lock, so a delayed queued/progress snapshot cannot overwrite a terminal
108    // result. Do not hold the queue state mutex while invoking the backend.
109    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 job ids ordered FIFO.
127    pending: VecDeque<MlJobId>,
128    /// All jobs known to the queue, terminal or not. Callers list /
129    /// inspect through this vec.
130    jobs: Vec<MlJob>,
131    /// True once `shutdown()` has been called.
132    shutting_down: bool,
133    /// Monotonic id counter. u128 so replicas can eventually mint
134    /// ids without coordination.
135    next_id: u128,
136}
137
138/// Queue + worker pool pair. Safe to clone — every clone shares the
139/// underlying queue via `Arc`.
140#[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    /// Spin up a queue with `worker_count` threads. `worker_fn` is
160    /// invoked once per job. No durable backend — see
161    /// [`Self::start_with_backend`] for the persisted variant.
162    pub fn start(worker_count: usize, worker_fn: MlWorkFn) -> Self {
163        Self::start_with(worker_count, worker_fn, None)
164    }
165
166    /// Spin up a queue that persists every state transition to
167    /// `backend`. On startup the queue rehydrates any non-terminal
168    /// records and re-enqueues them (they were Running when the
169    /// previous process died — treat as Queued on resume).
170    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        // Rehydrate first so `next_id` is strictly greater than every
184        // previously-issued id — collision-free across restarts.
185        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                    // A Running job from a prior process is now stuck —
195                    // requeue it so a worker can pick it up. Progress
196                    // resets to zero so the operator can tell it's a
197                    // resumed job from `SELECT * FROM ML_JOBS`.
198                    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        // Flush rehydrated pending-state back to the backend so the
225        // status change (Running → Queued) is durable.
226        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    /// Enqueue a new job. Returns the assigned id so the caller can
250    /// poll status later.
251    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    /// Fetch a job by id.
276    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    /// Snapshot every job (terminal + live). Callers use this to
285    /// back `SELECT * FROM ML_JOBS`.
286    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    /// Request cooperative cancellation. Returns `true` if the job
295    /// was still cancellable, `false` if it had already reached a
296    /// terminal state or does not exist.
297    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                // Drop from pending so no worker picks it up; workers
315                // already running observe `is_cancelled()` themselves.
316                guard.pending.retain(|pid| *pid != id);
317            }
318            snapshot
319        };
320        persist_job(&self.shared, &snapshot);
321        true
322    }
323
324    /// Stop every worker thread after it drains the pending queue.
325    /// This waits for running and already queued jobs to finish.
326    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        // Claim the next queued job, marking it running in the same
351        // critical section so two workers can't pick the same one.
352        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                // A cancel-while-queued slipped through between the
374                // wait and the pop; skip the work.
375                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                // The operator may have cancelled mid-flight — respect
401                // that state rather than overwriting it.
402                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        // One worker, occupied by a long job to force queueing.
494        let q = MlJobQueue::start(
495            1,
496            Arc::new(move |handle| {
497                if handle.id() == 1 {
498                    // Hold the first job long enough for #2 to sit queued.
499                    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        // Uses a barrier-like counter so the main thread can see the
531        // worker actually ran, then cancels and waits for the worker
532        // to observe the flag. `cancel()` flips terminal status
533        // immediately, so we cannot poll `is_terminal()` to prove the
534        // worker co-operated — we poll the observation counter.
535        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        // Raw record must exist and must reflect the completed status.
582        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        // Simulate submit's initial write arriving after the worker's final
606        // write, without relying on thread scheduling to expose the race.
607        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        // Simulate a prior process: one queued + one running + one
623        // completed job in the store.
624        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        // Both previously non-terminal jobs were re-run; the completed
685        // one stayed as-is.
686        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        // next_id must have advanced past the largest resumed id.
691        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}