1use crate::events::EventBroadcaster;
19use mold_core::ServerEvent;
20use serde::Serialize;
21use std::sync::{Arc, RwLock};
22use std::time::{SystemTime, UNIX_EPOCH};
23use tokio::sync::Notify;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, utoipa::ToSchema)]
32#[serde(rename_all = "snake_case")]
33pub enum JobLifecycle {
34 Queued,
35 Running,
36}
37
38#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
45pub struct JobEntry {
46 pub id: String,
47 pub model: String,
48 pub state: JobLifecycle,
49 pub started_at_unix_ms: u64,
50 pub position: usize,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub gpu: Option<usize>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub target_gpu: Option<usize>,
57}
58
59#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
63pub struct QueueListing {
64 pub entries: Vec<JobEntry>,
65}
66
67#[derive(Debug, Clone)]
68struct EntryInternal {
69 id: String,
70 model: String,
71 state: JobLifecycle,
72 started_at_unix_ms: u64,
73 gpu: Option<usize>,
74 target_gpu: Option<usize>,
75 cancel: Arc<Notify>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum TargetGpuUpdateError {
85 NotFound,
86 AlreadyRunning,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum QueuedJobCancelError {
92 NotFound,
93 AlreadyRunning,
94}
95
96pub struct JobRegistry {
101 inner: RwLock<Vec<EntryInternal>>,
102 events: Option<Arc<EventBroadcaster>>,
107}
108
109pub type SharedJobRegistry = Arc<JobRegistry>;
111
112impl JobRegistry {
113 pub fn new() -> SharedJobRegistry {
114 Arc::new(Self {
115 inner: RwLock::new(Vec::new()),
116 events: None,
117 })
118 }
119
120 pub fn with_events(events: Arc<EventBroadcaster>) -> SharedJobRegistry {
123 Arc::new(Self {
124 inner: RwLock::new(Vec::new()),
125 events: Some(events),
126 })
127 }
128
129 fn emit(&self, event: ServerEvent) {
133 if let Some(events) = &self.events {
134 events.publish(event);
135 }
136 }
137
138 pub fn register(&self, id: impl Into<String>, model: impl Into<String>) -> Arc<Notify> {
141 self.register_with_target_gpu(id, model, None)
142 }
143
144 pub fn register_with_target_gpu(
152 &self,
153 id: impl Into<String>,
154 model: impl Into<String>,
155 target_gpu: Option<usize>,
156 ) -> Arc<Notify> {
157 let started_at_unix_ms = SystemTime::now()
158 .duration_since(UNIX_EPOCH)
159 .unwrap_or_default()
160 .as_millis() as u64;
161 let id = id.into();
162 let model = model.into();
163 let cancel = Arc::new(Notify::new());
164 {
165 let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
166 entries.push(EntryInternal {
167 id: id.clone(),
168 model: model.clone(),
169 state: JobLifecycle::Queued,
170 started_at_unix_ms,
171 gpu: None,
172 target_gpu,
173 cancel: cancel.clone(),
174 });
175 }
176 self.emit(ServerEvent::JobQueued { id, model });
177 cancel
178 }
179
180 pub fn cancel_queued(&self, id: &str) -> Result<(), QueuedJobCancelError> {
190 {
191 let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
192 let Some(pos) = entries.iter().position(|e| e.id == id) else {
193 return Err(QueuedJobCancelError::NotFound);
194 };
195 if entries[pos].state == JobLifecycle::Running {
196 return Err(QueuedJobCancelError::AlreadyRunning);
197 }
198 let entry = entries.remove(pos);
199 entry.cancel.notify_one();
200 }
201 self.emit(ServerEvent::JobEnded { id: id.to_string() });
202 Ok(())
203 }
204
205 pub fn cancel_all_queued(&self) -> usize {
212 let cancelled_ids = {
213 let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
214 let mut ids = Vec::new();
215 entries.retain(|e| {
216 if e.state == JobLifecycle::Queued {
217 e.cancel.notify_one();
218 ids.push(e.id.clone());
219 false
220 } else {
221 true
222 }
223 });
224 ids
225 };
226 for id in &cancelled_ids {
227 self.emit(ServerEvent::JobEnded { id: id.clone() });
228 }
229 cancelled_ids.len()
230 }
231
232 pub fn mark_running(&self, id: &str, gpu: Option<usize>) {
235 let model = {
236 let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
237 entries.iter_mut().find(|e| e.id == id).map(|e| {
238 e.state = JobLifecycle::Running;
239 e.gpu = gpu;
240 e.target_gpu = None;
241 e.model.clone()
242 })
243 };
244 if let Some(model) = model {
245 self.emit(ServerEvent::JobStarted {
246 id: id.to_string(),
247 model,
248 gpu,
249 });
250 }
251 }
252
253 pub fn set_target_gpu(
254 &self,
255 id: &str,
256 target_gpu: Option<usize>,
257 ) -> Result<(), TargetGpuUpdateError> {
258 let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
259 let Some(e) = entries.iter_mut().find(|e| e.id == id) else {
260 return Err(TargetGpuUpdateError::NotFound);
261 };
262 if e.state == JobLifecycle::Running {
263 return Err(TargetGpuUpdateError::AlreadyRunning);
264 }
265 e.target_gpu = target_gpu;
266 Ok(())
267 }
268
269 pub fn target_gpu(&self, id: &str) -> Option<Option<usize>> {
270 let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
271 entries.iter().find(|e| e.id == id).map(|e| e.target_gpu)
272 }
273
274 pub fn entry(&self, id: &str) -> Option<JobEntry> {
275 let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
276 entries.iter().enumerate().find_map(|(i, e)| {
277 (e.id == id).then(|| JobEntry {
278 id: e.id.clone(),
279 model: e.model.clone(),
280 state: e.state,
281 started_at_unix_ms: e.started_at_unix_ms,
282 position: i,
283 gpu: e.gpu,
284 target_gpu: e.target_gpu,
285 })
286 })
287 }
288
289 pub fn remove(&self, id: &str) {
292 if id.is_empty() {
293 return;
294 }
295 let removed = {
296 let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
297 let before = entries.len();
298 entries.retain(|e| e.id != id);
299 entries.len() != before
300 };
301 if removed {
305 self.emit(ServerEvent::JobEnded { id: id.to_string() });
306 }
307 }
308
309 pub fn snapshot(&self) -> QueueListing {
312 let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
313 let out = entries
314 .iter()
315 .enumerate()
316 .map(|(i, e)| JobEntry {
317 id: e.id.clone(),
318 model: e.model.clone(),
319 state: e.state,
320 started_at_unix_ms: e.started_at_unix_ms,
321 position: i,
322 gpu: e.gpu,
323 target_gpu: e.target_gpu,
324 })
325 .collect();
326 QueueListing { entries: out }
327 }
328
329 pub fn len(&self) -> usize {
331 self.inner.read().unwrap_or_else(|e| e.into_inner()).len()
332 }
333
334 pub fn is_empty(&self) -> bool {
338 self.len() == 0
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn register_appends_in_fifo_order_with_queued_state() {
348 let reg = JobRegistry::new();
349 reg.register("a", "flux-dev:fp16");
350 reg.register("b", "sdxl:q8");
351 let snap = reg.snapshot();
352 assert_eq!(snap.entries.len(), 2);
353 assert_eq!(snap.entries[0].id, "a");
354 assert_eq!(snap.entries[0].position, 0);
355 assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
356 assert_eq!(snap.entries[1].id, "b");
357 assert_eq!(snap.entries[1].position, 1);
358 }
359
360 #[test]
361 fn mark_running_flips_state_and_records_gpu_ordinal() {
362 let reg = JobRegistry::new();
363 reg.register("a", "flux-dev:fp16");
364 reg.mark_running("a", Some(1));
365 let snap = reg.snapshot();
366 assert_eq!(snap.entries[0].state, JobLifecycle::Running);
367 assert_eq!(snap.entries[0].gpu, Some(1));
368 }
369
370 #[test]
371 fn queued_entries_can_carry_target_gpu_metadata() {
372 let reg = JobRegistry::new();
373 reg.register_with_target_gpu("a", "flux-dev:fp16", Some(1));
374 let snap = reg.snapshot();
375 assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
376 assert_eq!(snap.entries[0].target_gpu, Some(1));
377 assert_eq!(snap.entries[0].gpu, None);
378 }
379
380 #[test]
381 fn target_gpu_updates_only_apply_to_queued_entries() {
382 let reg = JobRegistry::new();
383 reg.register("a", "flux-dev:fp16");
384 reg.set_target_gpu("a", Some(1)).unwrap();
385 assert_eq!(reg.target_gpu("a"), Some(Some(1)));
386
387 reg.mark_running("a", Some(1));
388 let err = reg.set_target_gpu("a", None).unwrap_err();
389 assert_eq!(err, TargetGpuUpdateError::AlreadyRunning);
390 assert_eq!(reg.target_gpu("a"), Some(None));
391 }
392
393 #[test]
394 fn mark_running_is_a_noop_for_unknown_ids() {
395 let reg = JobRegistry::new();
396 reg.register("a", "flux-dev:fp16");
397 reg.mark_running("not-here", Some(0));
399 let snap = reg.snapshot();
400 assert_eq!(snap.entries.len(), 1);
401 assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
402 }
403
404 #[test]
405 fn remove_compacts_positions_for_the_survivors() {
406 let reg = JobRegistry::new();
407 reg.register("a", "flux-dev:fp16");
408 reg.register("b", "sdxl:q8");
409 reg.register("c", "ltx-video:q8");
410 reg.remove("b");
411 let snap = reg.snapshot();
412 assert_eq!(snap.entries.len(), 2);
413 assert_eq!(snap.entries[0].id, "a");
414 assert_eq!(snap.entries[0].position, 0);
415 assert_eq!(snap.entries[1].id, "c");
416 assert_eq!(snap.entries[1].position, 1);
417 }
418
419 #[test]
420 fn remove_is_idempotent_and_ignores_empty_ids() {
421 let reg = JobRegistry::new();
426 reg.register("a", "flux-dev:fp16");
427 reg.remove("a");
428 reg.remove("a"); reg.remove("");
430 reg.remove("never-existed");
431 assert!(reg.is_empty());
432 }
433
434 #[test]
435 fn cancel_queued_removes_the_entry() {
436 let reg = JobRegistry::new();
437 reg.register("a", "flux-dev:fp16");
438 reg.register("b", "sdxl:q8");
439 reg.cancel_queued("a").unwrap();
440 let snap = reg.snapshot();
441 assert_eq!(snap.entries.len(), 1);
442 assert_eq!(snap.entries[0].id, "b");
443 assert_eq!(snap.entries[0].position, 0);
444 }
445
446 #[test]
447 fn cancel_queued_rejects_running_jobs_and_keeps_the_entry() {
448 let reg = JobRegistry::new();
449 reg.register("a", "flux-dev:fp16");
450 reg.mark_running("a", Some(0));
451 let err = reg.cancel_queued("a").unwrap_err();
452 assert_eq!(err, QueuedJobCancelError::AlreadyRunning);
453 assert_eq!(reg.len(), 1, "running entry must survive a cancel attempt");
454 }
455
456 #[test]
457 fn cancel_queued_unknown_id_is_not_found() {
458 let reg = JobRegistry::new();
459 let err = reg.cancel_queued("never-existed").unwrap_err();
460 assert_eq!(err, QueuedJobCancelError::NotFound);
461 }
462
463 #[test]
464 fn cancel_all_queued_removes_only_queued_and_returns_count() {
465 let reg = JobRegistry::new();
466 reg.register("a", "flux-dev:fp16");
467 reg.register("b", "sdxl:q8");
468 reg.register("c", "ltx-video:q8");
469 reg.mark_running("b", Some(0));
471
472 let cancelled = reg.cancel_all_queued();
473 assert_eq!(cancelled, 2, "both queued jobs cancelled, running one kept");
474 let snap = reg.snapshot();
475 assert_eq!(snap.entries.len(), 1);
476 assert_eq!(snap.entries[0].id, "b");
477 assert_eq!(snap.entries[0].state, JobLifecycle::Running);
478 }
479
480 #[test]
481 fn cancel_all_queued_on_empty_registry_returns_zero() {
482 let reg = JobRegistry::new();
483 assert_eq!(reg.cancel_all_queued(), 0);
484 }
485
486 #[tokio::test]
487 async fn cancel_all_queued_signals_every_registered_waiter() {
488 let reg = JobRegistry::new();
492 let cancel_a = reg.register("a", "flux-dev:fp16");
493 let cancel_b = reg.register("b", "sdxl:q8");
494 assert_eq!(reg.cancel_all_queued(), 2);
495 tokio::time::timeout(std::time::Duration::from_secs(1), cancel_a.notified())
496 .await
497 .expect("cancel signal for a must resolve");
498 tokio::time::timeout(std::time::Duration::from_secs(1), cancel_b.notified())
499 .await
500 .expect("cancel signal for b must resolve");
501 }
502
503 #[tokio::test]
504 async fn cancel_queued_signals_the_registered_waiter() {
505 let reg = JobRegistry::new();
509 let cancel = reg.register("a", "flux-dev:fp16");
510 reg.cancel_queued("a").unwrap();
511 tokio::time::timeout(std::time::Duration::from_secs(1), cancel.notified())
512 .await
513 .expect("cancel signal must resolve the waiter");
514 }
515
516 #[test]
517 fn snapshot_serializes_with_snake_case_state_and_omits_gpu_when_queued() {
518 let reg = JobRegistry::new();
522 reg.register("a", "flux-dev:fp16");
523 let snap = reg.snapshot();
524 let json = serde_json::to_string(&snap.entries[0]).unwrap();
525 assert!(json.contains(r#""state":"queued""#), "got: {json}");
526 assert!(
527 !json.contains("gpu"),
528 "queued row leaked a gpu field: {json}"
529 );
530
531 reg.mark_running("a", Some(0));
532 let snap2 = reg.snapshot();
533 let json2 = serde_json::to_string(&snap2.entries[0]).unwrap();
534 assert!(json2.contains(r#""state":"running""#));
535 assert!(json2.contains(r#""gpu":0"#));
536 }
537
538 mod event_emission {
539 use super::*;
540 use crate::events::EventBroadcaster;
541 use mold_core::ServerEvent;
542 use tokio::sync::broadcast::error::TryRecvError;
543
544 fn wired() -> (
545 SharedJobRegistry,
546 tokio::sync::broadcast::Receiver<ServerEvent>,
547 ) {
548 let events = EventBroadcaster::new();
549 let rx = events.subscribe();
550 (JobRegistry::with_events(events), rx)
551 }
552
553 #[test]
554 fn register_emits_job_queued() {
555 let (reg, mut rx) = wired();
556 reg.register("a", "flux-dev:fp16");
557 match rx.try_recv().unwrap() {
558 ServerEvent::JobQueued { id, model } => {
559 assert_eq!(id, "a");
560 assert_eq!(model, "flux-dev:fp16");
561 }
562 other => panic!("expected job_queued, got {other:?}"),
563 }
564 }
565
566 #[test]
567 fn mark_running_emits_job_started_with_model_and_gpu() {
568 let (reg, mut rx) = wired();
569 reg.register("a", "flux-dev:fp16");
570 let _ = rx.try_recv(); reg.mark_running("a", Some(1));
572 match rx.try_recv().unwrap() {
573 ServerEvent::JobStarted { id, model, gpu } => {
574 assert_eq!(id, "a");
575 assert_eq!(model, "flux-dev:fp16");
576 assert_eq!(gpu, Some(1));
577 }
578 other => panic!("expected job_started, got {other:?}"),
579 }
580 }
581
582 #[test]
583 fn mark_running_unknown_id_emits_nothing() {
584 let (reg, mut rx) = wired();
585 reg.mark_running("ghost", None);
586 assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
587 }
588
589 #[test]
590 fn remove_emits_job_ended_exactly_once_across_double_call() {
591 let (reg, mut rx) = wired();
592 reg.register("a", "flux-dev:fp16");
593 let _ = rx.try_recv(); reg.remove("a");
595 reg.remove("a"); match rx.try_recv().unwrap() {
597 ServerEvent::JobEnded { id } => assert_eq!(id, "a"),
598 other => panic!("expected job_ended, got {other:?}"),
599 }
600 assert!(
601 matches!(rx.try_recv(), Err(TryRecvError::Empty)),
602 "second remove must not emit a duplicate job_ended"
603 );
604 }
605
606 #[test]
607 fn cancel_queued_emits_job_ended() {
608 let (reg, mut rx) = wired();
609 reg.register("a", "flux-dev:fp16");
610 let _ = rx.try_recv(); reg.cancel_queued("a").unwrap();
612 match rx.try_recv().unwrap() {
613 ServerEvent::JobEnded { id } => assert_eq!(id, "a"),
614 other => panic!("expected job_ended, got {other:?}"),
615 }
616 }
617
618 #[test]
619 fn cancel_all_queued_emits_exactly_one_job_ended_per_cancelled_job() {
620 let (reg, mut rx) = wired();
621 reg.register("a", "flux-dev:fp16");
622 reg.register("b", "sdxl:q8");
623 reg.register("c", "ltx-video:q8");
624 reg.mark_running("c", Some(0));
625 while rx.try_recv().is_ok() {}
627
628 assert_eq!(reg.cancel_all_queued(), 2);
629 let mut ended = Vec::new();
630 while let Ok(ev) = rx.try_recv() {
631 match ev {
632 ServerEvent::JobEnded { id } => ended.push(id),
633 other => panic!("expected only job_ended, got {other:?}"),
634 }
635 }
636 ended.sort();
637 assert_eq!(ended, vec!["a".to_string(), "b".to_string()]);
638 }
639 }
640}