1use std::collections::VecDeque;
2use std::sync::Arc;
3
4use base64::Engine as _;
5use mold_core::{
6 ImageData, OutputFormat, OutputMetadata, SseCompleteEvent, SseErrorEvent, SseProgressEvent,
7};
8use mold_db::{GenerationRecord, MetadataDb, RecordSource};
9use sha2::{Digest, Sha256};
10use std::sync::atomic::Ordering;
11use std::time::{Instant, SystemTime, UNIX_EPOCH};
12
13use crate::gpu_pool::GpuJob;
14use crate::model_manager;
15use crate::state::{
16 ActiveGenerationSnapshot, AppState, GenerationJob, GenerationJobResult, SseMessage,
17};
18
19fn progress_to_sse(event: mold_inference::ProgressEvent) -> SseProgressEvent {
21 event.into()
22}
23
24pub(crate) fn clean_error_message(e: &anyhow::Error) -> String {
30 let full = format!("{e:#}");
31 let mut lines: Vec<&str> = Vec::new();
32 for line in full.lines() {
33 let trimmed = line.trim_start();
34 if (trimmed.starts_with("0:") || trimmed.starts_with("1:"))
35 && trimmed.len() > 3
36 && trimmed
37 .as_bytes()
38 .first()
39 .is_some_and(|b| b.is_ascii_digit())
40 {
41 break;
42 }
43 if trimmed.len() > 2
44 && trimmed.as_bytes()[0].is_ascii_digit()
45 && trimmed.contains("::")
46 && trimmed.contains("at ")
47 {
48 break;
49 }
50 lines.push(line);
51 }
52 let msg = lines.join("\n").trim().to_string();
53 if msg.is_empty() {
54 format!("{}", e.root_cause())
55 } else {
56 msg
57 }
58}
59
60fn set_active_generation(state: &AppState, model: &str, prompt: &str) {
61 let prompt_sha256 = format!("{:x}", Sha256::digest(prompt.as_bytes()));
62 let started_at_unix_ms = SystemTime::now()
63 .duration_since(UNIX_EPOCH)
64 .unwrap_or_default()
65 .as_millis() as u64;
66
67 let mut active = state
68 .active_generation
69 .write()
70 .unwrap_or_else(|e| e.into_inner());
71 *active = Some(ActiveGenerationSnapshot {
72 model: model.to_string(),
73 prompt_sha256,
74 started_at_unix_ms,
75 started_at: Instant::now(),
76 });
77}
78
79fn clear_active_generation(state: &AppState) {
80 let mut active = state
81 .active_generation
82 .write()
83 .unwrap_or_else(|e| e.into_inner());
84 *active = None;
85}
86
87pub(crate) fn save_image_to_dir(
97 dir: &std::path::Path,
98 img: &mold_core::ImageData,
99 model: &str,
100 batch_size: u32,
101 metadata: Option<&OutputMetadata>,
102 generation_time_ms: Option<i64>,
103 db: Option<&MetadataDb>,
104) {
105 if let Err(e) = std::fs::create_dir_all(dir) {
106 tracing::warn!("failed to create output dir {}: {e}", dir.display());
107 return;
108 }
109 let now = SystemTime::now()
110 .duration_since(UNIX_EPOCH)
111 .unwrap_or_default();
112 let timestamp_ms = now.as_millis() as u64;
113 let ext = img.format.to_string();
114 let filename =
115 mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
116 let path = dir.join(&filename);
117 match std::fs::write(&path, &img.data) {
118 Ok(()) => tracing::info!("saved image to {}", path.display()),
119 Err(e) => {
120 tracing::warn!("failed to save image to {}: {e}", path.display());
121 return;
122 }
123 }
124 if let (Some(db), Some(meta)) = (db, metadata) {
125 let mut rec = GenerationRecord::from_save(
126 dir,
127 filename,
128 img.format,
129 meta.clone(),
130 RecordSource::Server,
131 now.as_millis() as i64,
132 );
133 rec.stat_from_disk(&path);
134 rec.generation_time_ms = generation_time_ms;
135 rec.hostname = hostname_string();
136 rec.backend = current_backend_label();
137 if let Err(e) = db.upsert(&rec) {
138 tracing::warn!("metadata DB upsert failed for {}: {e:#}", rec.filename);
139 }
140 }
141}
142
143#[allow(clippy::too_many_arguments)]
153pub(crate) fn save_video_to_dir(
154 dir: &std::path::Path,
155 bytes: &[u8],
156 gif_preview: &[u8],
157 format: OutputFormat,
158 model: &str,
159 metadata: &OutputMetadata,
160 generation_time_ms: Option<i64>,
161 db: Option<&MetadataDb>,
162) {
163 if let Err(e) = std::fs::create_dir_all(dir) {
164 tracing::warn!("failed to create output dir {}: {e}", dir.display());
165 return;
166 }
167 let now = SystemTime::now()
168 .duration_since(UNIX_EPOCH)
169 .unwrap_or_default();
170 let ts = now.as_millis() as u64;
171 let ext = format.extension();
172 let filename = mold_core::default_output_filename(model, ts, ext, 1, 0);
173 let path = dir.join(&filename);
174 if let Err(e) = std::fs::write(&path, bytes) {
175 tracing::error!("failed to save video to {}: {e}", path.display());
176 return;
177 }
178 if !gif_preview.is_empty() {
179 save_video_preview_gif(&filename, gif_preview);
180 }
181 if let Some(db) = db {
182 let mut rec = GenerationRecord::from_save(
183 dir,
184 filename,
185 format,
186 metadata.clone(),
187 RecordSource::Server,
188 now.as_millis() as i64,
189 );
190 rec.stat_from_disk(&path);
191 rec.generation_time_ms = generation_time_ms;
192 rec.hostname = hostname_string();
193 rec.backend = current_backend_label();
194 if let Err(e) = db.upsert(&rec) {
195 tracing::warn!("metadata DB upsert failed for {}: {e:#}", rec.filename);
196 }
197 }
198}
199
200pub(crate) fn save_video_preview_gif(filename: &str, gif_bytes: &[u8]) {
209 let preview_dir = mold_core::Config::mold_dir()
210 .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
211 .join("cache")
212 .join("previews");
213 save_video_preview_gif_to(&preview_dir, filename, gif_bytes);
214}
215
216fn save_video_preview_gif_to(preview_dir: &std::path::Path, filename: &str, gif_bytes: &[u8]) {
220 if let Err(e) = std::fs::create_dir_all(preview_dir) {
221 tracing::warn!(
222 "failed to create preview cache dir {}: {e}",
223 preview_dir.display()
224 );
225 return;
226 }
227 let preview_path = preview_dir.join(format!("{filename}.preview.gif"));
228 if let Err(e) = std::fs::write(&preview_path, gif_bytes) {
229 tracing::warn!(
230 "failed to write preview gif {}: {e}",
231 preview_path.display()
232 );
233 }
234}
235
236fn hostname_string() -> Option<String> {
238 hostname::get().ok().and_then(|s| s.into_string().ok())
239}
240
241fn current_backend_label() -> Option<String> {
243 if cfg!(feature = "cuda") {
244 Some("cuda".into())
245 } else if cfg!(feature = "metal") {
246 Some("metal".into())
247 } else {
248 Some("cpu".into())
249 }
250}
251
252pub(crate) fn build_sse_complete_event(
269 response: &mold_core::GenerateResponse,
270 img: &mold_core::ImageData,
271) -> SseCompleteEvent {
272 let b64 = base64::engine::general_purpose::STANDARD;
273 if let Some(ref video) = response.video {
274 SseCompleteEvent {
275 image: b64.encode(&video.data),
276 format: video.format,
277 width: video.width,
278 height: video.height,
279 seed_used: response.seed_used,
280 generation_time_ms: response.generation_time_ms,
281 model: response.model.clone(),
282 video_frames: Some(video.frames),
283 video_fps: Some(video.fps),
284 video_thumbnail: Some(b64.encode(&video.thumbnail)),
285 video_gif_preview: if video.gif_preview.is_empty() {
286 None
287 } else {
288 Some(b64.encode(&video.gif_preview))
289 },
290 video_has_audio: video.has_audio,
291 video_duration_ms: video.duration_ms,
292 video_audio_sample_rate: video.audio_sample_rate,
293 video_audio_channels: video.audio_channels,
294 gpu: response.gpu,
295 }
296 } else {
297 SseCompleteEvent {
298 image: b64.encode(&img.data),
299 format: img.format,
300 width: img.width,
301 height: img.height,
302 seed_used: response.seed_used,
303 generation_time_ms: response.generation_time_ms,
304 model: response.model.clone(),
305 video_frames: None,
306 video_fps: None,
307 video_thumbnail: None,
308 video_gif_preview: None,
309 video_has_audio: false,
310 video_duration_ms: None,
311 video_audio_sample_rate: None,
312 video_audio_channels: None,
313 gpu: response.gpu,
314 }
315 }
316}
317
318pub async fn run_queue_worker(
323 mut job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
324 state: AppState,
325) {
326 tracing::debug!("generation queue worker started");
327 let buffer_size = resolve_lookahead_buffer();
328 let max_deferrals = resolve_max_deferrals();
329 let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(buffer_size);
330
331 loop {
332 if buffer.is_empty() {
333 match job_rx.recv().await {
334 Some(j) => buffer.push_back(BufferedJob::new(j)),
335 None => break,
336 }
337 }
338 top_up_buffer(&mut buffer, &mut job_rx, buffer_size);
340
341 let loaded = single_gpu_loaded_models(&state).await;
342 let job = pick_next_job(&mut buffer, &loaded, max_deferrals);
343 let job_id = job.id.clone();
344
345 #[cfg(feature = "metrics")]
346 crate::metrics::record_queue_depth(state.queue.pending());
347 process_job(&state, job).await;
348 state.queue.decrement();
349 state.job_registry.remove(&job_id);
353 #[cfg(feature = "metrics")]
354 crate::metrics::record_queue_depth(state.queue.pending());
355 }
356 tracing::info!("generation queue worker shutting down");
357}
358
359async fn single_gpu_loaded_models(state: &AppState) -> std::collections::HashSet<String> {
360 let mut set = std::collections::HashSet::new();
361 let cache = state.model_cache.lock().await;
362 if let Some(name) = cache.active_model() {
363 set.insert(name.to_string());
364 }
365 set
366}
367
368fn multi_gpu_loaded_models(state: &AppState) -> std::collections::HashSet<String> {
374 let mut set = std::collections::HashSet::new();
375 for worker in &state.gpu_pool.workers {
376 if let Ok(active_gen) = worker.active_generation.read() {
377 if let Some(g) = active_gen.as_ref() {
378 set.insert(g.model.clone());
379 }
380 }
381 if let Ok(cache) = worker.model_cache.lock() {
382 if let Some(name) = cache.active_model() {
383 set.insert(name.to_string());
384 }
385 }
386 }
387 set
388}
389
390pub(crate) struct BufferedJob {
394 pub(crate) job: GenerationJob,
395 pub(crate) deferred: usize,
396}
397
398impl BufferedJob {
399 fn new(job: GenerationJob) -> Self {
400 Self { job, deferred: 0 }
401 }
402}
403
404pub(crate) fn top_up_buffer(
410 buffer: &mut VecDeque<BufferedJob>,
411 job_rx: &mut tokio::sync::mpsc::Receiver<GenerationJob>,
412 buffer_size: usize,
413) {
414 while buffer.len() < buffer_size {
415 match job_rx.try_recv() {
416 Ok(j) => buffer.push_back(BufferedJob::new(j)),
417 Err(_) => break,
418 }
419 }
420}
421
422pub(crate) fn pick_next_job(
432 buffer: &mut VecDeque<BufferedJob>,
433 loaded: &std::collections::HashSet<String>,
434 max_deferrals: usize,
435) -> GenerationJob {
436 debug_assert!(
437 !buffer.is_empty(),
438 "pick_next_job requires non-empty buffer"
439 );
440
441 if let Some(head) = buffer.front() {
443 if head.deferred >= max_deferrals {
444 return buffer.pop_front().expect("checked non-empty").job;
445 }
446 }
447
448 let pick_idx = buffer
450 .iter()
451 .position(|b| loaded.contains(&b.job.request.model))
452 .unwrap_or(0);
453
454 if pick_idx > 0 {
455 for (i, b) in buffer.iter_mut().enumerate() {
456 if i < pick_idx {
457 b.deferred += 1;
458 }
459 }
460 let model = buffer[pick_idx].job.request.model.clone();
461 tracing::debug!(
462 picked_model = %model,
463 head_model = %buffer.front().map(|b| b.job.request.model.as_str()).unwrap_or(""),
464 picked_index = pick_idx,
465 "queue reorder picked non-head job"
466 );
467 #[cfg(feature = "metrics")]
468 crate::metrics::record_queue_reorder();
469 }
470
471 buffer.remove(pick_idx).expect("pick_idx in range").job
472}
473
474pub(crate) const DEFAULT_LOOKAHEAD_BUFFER: usize = 8;
475pub(crate) const DEFAULT_MAX_DEFERRALS: usize = 3;
476pub(crate) const LOOKAHEAD_BUFFER_ENV: &str = "MOLD_QUEUE_LOOKAHEAD_BUFFER";
477pub(crate) const MAX_DEFERRALS_ENV: &str = "MOLD_QUEUE_MAX_DEFERRALS";
478const LOOKAHEAD_BUFFER_LOWER: usize = 1;
479const LOOKAHEAD_BUFFER_UPPER: usize = 64;
480const MAX_DEFERRALS_UPPER: usize = 32;
481
482pub(crate) fn resolve_lookahead_buffer() -> usize {
486 match std::env::var(LOOKAHEAD_BUFFER_ENV) {
487 Ok(raw) => match raw.trim().parse::<usize>() {
488 Ok(n) if (LOOKAHEAD_BUFFER_LOWER..=LOOKAHEAD_BUFFER_UPPER).contains(&n) => n,
489 Ok(n) => {
490 tracing::warn!(
491 env = LOOKAHEAD_BUFFER_ENV,
492 value = n,
493 lower = LOOKAHEAD_BUFFER_LOWER,
494 upper = LOOKAHEAD_BUFFER_UPPER,
495 "ignoring out-of-range queue lookahead buffer; using default"
496 );
497 DEFAULT_LOOKAHEAD_BUFFER
498 }
499 Err(e) => {
500 tracing::warn!(
501 env = LOOKAHEAD_BUFFER_ENV,
502 raw = %raw,
503 error = %e,
504 "ignoring unparseable queue lookahead buffer; using default"
505 );
506 DEFAULT_LOOKAHEAD_BUFFER
507 }
508 },
509 Err(_) => DEFAULT_LOOKAHEAD_BUFFER,
510 }
511}
512
513pub(crate) fn resolve_max_deferrals() -> usize {
516 match std::env::var(MAX_DEFERRALS_ENV) {
517 Ok(raw) => match raw.trim().parse::<usize>() {
518 Ok(n) if n <= MAX_DEFERRALS_UPPER => n,
519 Ok(n) => {
520 tracing::warn!(
521 env = MAX_DEFERRALS_ENV,
522 value = n,
523 upper = MAX_DEFERRALS_UPPER,
524 "ignoring out-of-range queue max-deferrals; using default"
525 );
526 DEFAULT_MAX_DEFERRALS
527 }
528 Err(e) => {
529 tracing::warn!(
530 env = MAX_DEFERRALS_ENV,
531 raw = %raw,
532 error = %e,
533 "ignoring unparseable queue max-deferrals; using default"
534 );
535 DEFAULT_MAX_DEFERRALS
536 }
537 },
538 Err(_) => DEFAULT_MAX_DEFERRALS,
539 }
540}
541
542async fn process_job(state: &AppState, job: GenerationJob) {
543 if job.result_tx.is_closed() {
545 tracing::debug!("skipping queued job — client disconnected");
546 return;
547 }
548
549 state.job_registry.mark_running(&job.id, None);
552
553 if let Some(ref tx) = job.progress_tx {
557 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued {
558 position: 0,
559 id: job.id.clone(),
560 }));
561 }
562
563 let progress_callback = job.progress_tx.as_ref().map(|tx| {
565 let tx = tx.clone();
566 Arc::new(move |event: mold_inference::ProgressEvent| {
567 let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
568 }) as model_manager::EngineProgressCallback
569 });
570
571 let activation_hint = model_manager::activation_hint_for_request(state, &job.request).await;
572 let request_has_lora = model_manager::request_has_effective_lora(&job.request);
573 if let Err(api_err) = model_manager::ensure_model_ready(
574 state,
575 &job.request.model,
576 progress_callback,
577 activation_hint,
578 request_has_lora,
579 )
580 .await
581 {
582 let err_msg = api_err.error.clone();
583 if let Some(ref tx) = job.progress_tx {
584 let _ = tx.send(SseMessage::Error(SseErrorEvent {
585 message: err_msg.clone(),
586 }));
587 }
588 let _ = job.result_tx.send(Err(err_msg));
589 return;
590 }
591
592 #[cfg(target_os = "macos")]
594 if let Some(available) = mold_inference::device::available_system_memory_bytes() {
595 if available < 1_000_000_000 {
596 tracing::warn!(
597 available_mb = available / 1_000_000,
598 "low memory before inference — system may become unstable"
599 );
600 }
601 }
602
603 let taken = {
608 let mut cache = state.model_cache.lock().await;
609 cache.take(&job.request.model)
610 };
611 let Some(mut cached_engine) = taken else {
612 let err_msg = "no engine available after model readiness check".to_string();
613 if let Some(ref tx) = job.progress_tx {
614 let _ = tx.send(SseMessage::Error(SseErrorEvent {
615 message: err_msg.clone(),
616 }));
617 }
618 let _ = job.result_tx.send(Err(err_msg));
619 return;
620 };
621
622 let active_gen = state.active_generation.clone();
623 let gen_req = job.request.clone();
624 let progress_tx = job.progress_tx.clone();
625
626 set_active_generation(state, &job.request.model, &job.request.prompt);
627
628 let was_streaming = progress_tx.is_some();
633 if let Some(ref ptx) = progress_tx {
634 let ptx = ptx.clone();
635 cached_engine.engine.set_on_progress(Box::new(move |event| {
636 let _ = ptx.send(SseMessage::Progress(progress_to_sse(event)));
637 }));
638 } else {
639 cached_engine.engine.clear_on_progress();
640 }
641
642 #[cfg(feature = "metrics")]
643 let inference_start = Instant::now();
644 let rss_before = crate::resources::ram_snapshot().used_by_mold;
648 let join_result = tokio::task::spawn_blocking(move || {
652 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
653 cached_engine.engine.generate(&gen_req)
654 }));
655 if was_streaming {
656 cached_engine.engine.clear_on_progress();
657 }
658 (cached_engine, result)
659 })
660 .await;
661
662 let rss_after = crate::resources::ram_snapshot().used_by_mold;
663 let rss_delta = rss_after as i64 - rss_before as i64;
664 tracing::info!(
665 model = %job.request.model,
666 rss_before_mb = rss_before / 1_000_000,
667 rss_after_mb = rss_after / 1_000_000,
668 rss_delta_mb = rss_delta / 1_000_000,
669 "generation memory delta"
670 );
671
672 #[cfg(feature = "metrics")]
673 let inference_duration = inference_start.elapsed().as_secs_f64();
674
675 let result = match join_result {
684 Ok((cached_engine, panic_or_result)) => {
685 {
686 let mut cache = state.model_cache.lock().await;
687 cache.restore(cached_engine);
688 }
689 clear_active_generation(state);
690 Ok(panic_or_result)
691 }
692 Err(join_err) => {
693 {
694 let mut cache = state.model_cache.lock().await;
695 cache.clear_in_flight(&job.request.model);
696 }
697 clear_active_generation(state);
698 Err(join_err)
699 }
700 };
701
702 match result {
703 Ok(Ok(Ok(mut response))) => {
704 #[cfg(feature = "metrics")]
705 crate::metrics::record_generation(&job.request.model, inference_duration);
706
707 if response.images.is_empty() && response.video.is_none() {
708 let err_msg = "generation error: engine returned no images or video".to_string();
709 if let Some(ref tx) = job.progress_tx {
710 let _ = tx.send(SseMessage::Error(SseErrorEvent {
711 message: err_msg.clone(),
712 }));
713 }
714 let _ = job.result_tx.send(Err(err_msg));
715 return;
716 }
717 let img = if !response.images.is_empty() {
720 response.images.remove(0)
721 } else if let Some(ref video) = response.video {
722 ImageData {
723 data: video.thumbnail.clone(),
724 format: OutputFormat::Png,
725 width: video.width,
726 height: video.height,
727 index: 0,
728 }
729 } else {
730 unreachable!("checked above");
731 };
732
733 if let Some(ref dir) = job.output_dir {
737 let dir = dir.clone();
738 let model = job.request.model.clone();
739 let batch_size = job.request.batch_size;
740 let generation_time_ms = response.generation_time_ms as i64;
741 let metadata = OutputMetadata::from_generate_request(
742 &job.request,
743 response.seed_used,
744 None,
745 mold_core::build_info::version_string(),
746 );
747 let db = state.metadata_db.clone();
748 if let Some(ref video) = response.video {
749 let video_data = video.data.clone();
750 let video_gif_preview = video.gif_preview.clone();
751 let video_format = video.format;
752 let video_metadata = metadata.clone();
753 tokio::task::spawn_blocking(move || {
754 save_video_to_dir(
755 &dir,
756 &video_data,
757 &video_gif_preview,
758 video_format,
759 &model,
760 &video_metadata,
761 Some(generation_time_ms),
762 db.as_ref().as_ref(),
763 );
764 });
765 } else {
766 let img_clone = img.clone();
767 let metadata_clone = metadata.clone();
768 tokio::task::spawn_blocking(move || {
769 save_image_to_dir(
770 &dir,
771 &img_clone,
772 &model,
773 batch_size,
774 Some(&metadata_clone),
775 Some(generation_time_ms),
776 db.as_ref().as_ref(),
777 );
778 });
779 }
780 }
781
782 if let Some(ref tx) = job.progress_tx {
784 let event = build_sse_complete_event(&response, &img);
785 let _ = tx.send(SseMessage::Complete(event));
786 }
787
788 let _ = job.result_tx.send(Ok(GenerationJobResult {
790 image: img,
791 response,
792 }));
793 }
794 Ok(Ok(Err(e))) => {
795 #[cfg(feature = "metrics")]
796 crate::metrics::record_generation_error(&job.request.model);
797
798 *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
799 tracing::error!("generation error: {e:#}");
800 let err_msg = format!("generation error: {}", clean_error_message(&e));
801 if let Some(ref tx) = job.progress_tx {
802 let _ = tx.send(SseMessage::Error(SseErrorEvent {
803 message: err_msg.clone(),
804 }));
805 }
806 let _ = job.result_tx.send(Err(err_msg));
807 }
808 Ok(Err(panic_payload)) => {
809 #[cfg(feature = "metrics")]
810 crate::metrics::record_generation_error(&job.request.model);
811
812 *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
813 let msg = panic_payload
814 .downcast_ref::<String>()
815 .map(|s| s.as_str())
816 .or_else(|| panic_payload.downcast_ref::<&str>().copied())
817 .unwrap_or("unknown panic");
818 tracing::error!("inference panicked: {msg}");
819 let err_msg = format!("inference panicked: {msg}");
820 if let Some(ref tx) = job.progress_tx {
821 let _ = tx.send(SseMessage::Error(SseErrorEvent {
822 message: err_msg.clone(),
823 }));
824 }
825 let _ = job.result_tx.send(Err(err_msg));
826 }
827 Err(join_err) => {
828 #[cfg(feature = "metrics")]
829 crate::metrics::record_generation_error(&job.request.model);
830
831 *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
832 tracing::error!("inference task join error: {join_err:?}");
833 let err_msg = "inference task failed".to_string();
834 if let Some(ref tx) = job.progress_tx {
835 let _ = tx.send(SseMessage::Error(SseErrorEvent {
836 message: err_msg.clone(),
837 }));
838 }
839 let _ = job.result_tx.send(Err(err_msg));
840 }
841 }
842}
843
844pub async fn run_queue_dispatcher(
854 mut job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
855 state: AppState,
856) {
857 tracing::debug!("multi-GPU queue dispatcher started");
858 let buffer_size = resolve_lookahead_buffer();
859 let max_deferrals = resolve_max_deferrals();
860 let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(buffer_size);
861
862 loop {
863 if buffer.is_empty() {
864 match job_rx.recv().await {
865 Some(j) => buffer.push_back(BufferedJob::new(j)),
866 None => break,
867 }
868 }
869 top_up_buffer(&mut buffer, &mut job_rx, buffer_size);
870
871 let loaded = multi_gpu_loaded_models(&state);
872 let job = pick_next_job(&mut buffer, &loaded, max_deferrals);
873
874 #[cfg(feature = "metrics")]
875 crate::metrics::record_queue_depth(state.queue.pending());
876
877 let job_id = job.id.clone();
878 let model_name = job.request.model.clone();
879 let estimated_vram = estimate_model_vram(&model_name);
880
881 if let Some(err_msg) = crate::gpu_pool::model_unschedulable_message(&model_name) {
882 tracing::warn!(model = %model_name, "{err_msg}");
883 if let Some(tx) = job.progress_tx {
884 let _ = tx.send(SseMessage::Error(SseErrorEvent {
885 message: err_msg.clone(),
886 }));
887 }
888 let _ = job.result_tx.send(Err(err_msg));
889 state.queue.decrement();
890 state.job_registry.remove(&job_id);
891 #[cfg(feature = "metrics")]
892 crate::metrics::record_queue_depth(state.queue.pending());
893 continue;
894 }
895
896 let preferred_gpu = match state
897 .gpu_pool
898 .resolve_explicit_placement_gpu(job.request.placement.as_ref())
899 {
900 Ok(ordinal) => ordinal,
901 Err(err_msg) => {
902 tracing::warn!(model = %model_name, "{err_msg}");
903 if let Some(tx) = job.progress_tx {
904 let _ = tx.send(SseMessage::Error(SseErrorEvent {
905 message: err_msg.clone(),
906 }));
907 }
908 let _ = job.result_tx.send(Err(err_msg));
909 state.queue.decrement();
910 state.job_registry.remove(&job_id);
911 #[cfg(feature = "metrics")]
912 crate::metrics::record_queue_depth(state.queue.pending());
913 continue;
914 }
915 };
916
917 if job.result_tx.is_closed() {
918 tracing::debug!(model = %model_name, "skipping queued multi-GPU job — client disconnected");
919 state.queue.decrement();
920 state.job_registry.remove(&job_id);
921 #[cfg(feature = "metrics")]
922 crate::metrics::record_queue_depth(state.queue.pending());
923 continue;
924 }
925
926 let mut gpu_job = Some(GpuJob {
928 id: job.id.clone(),
929 model: model_name.clone(),
930 request: job.request,
931 progress_tx: job.progress_tx,
932 result_tx: job.result_tx,
933 output_dir: job.output_dir,
934 config: state.config.clone(),
935 metadata_db: state.metadata_db.clone(),
936 queue: state.queue.clone(),
937 registry: state.job_registry.clone(),
938 });
939
940 let mut skip: Vec<usize> = if preferred_gpu.is_none() {
941 let failed = crate::gpu_pool::failed_ordinals_for_model(&model_name);
942 if failed.len() < state.gpu_pool.worker_count() {
943 failed
944 } else {
945 Vec::new()
946 }
947 } else {
948 Vec::new()
949 };
950 let mut dispatched = false;
951
952 while !dispatched {
953 if gpu_job
954 .as_ref()
955 .is_some_and(|pending| pending.result_tx.is_closed())
956 {
957 tracing::debug!(
958 model = %model_name,
959 "dropping queued multi-GPU job before dispatch — client disconnected"
960 );
961 state.queue.decrement();
962 state.job_registry.remove(&job_id);
963 break;
964 }
965
966 let worker = if let Some(ordinal) = preferred_gpu {
967 state.gpu_pool.worker_by_ordinal(ordinal)
968 } else {
969 state
970 .gpu_pool
971 .select_worker_excluding(&model_name, estimated_vram, &skip)
972 };
973
974 let Some(worker) = worker else {
975 if preferred_gpu.is_none() && state.gpu_pool.worker_count() > 0 {
976 tracing::warn!(
977 model = %model_name,
978 "all GPU workers are temporarily unavailable; keeping job queued"
979 );
980 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
981 continue;
982 }
983 let rejected = gpu_job
984 .take()
985 .expect("gpu_job retained after failed dispatch");
986 let err_msg = if state.gpu_pool.worker_count() == 0 {
987 format!("no GPU available for model {model_name}")
988 } else if let Some(ordinal) = preferred_gpu {
989 format!("gpu:{ordinal} is not available for model {model_name}")
990 } else {
991 format!("no GPU worker available for model {model_name}")
992 };
993 tracing::error!(model = %model_name, "{err_msg}");
994 if let Some(tx) = rejected.progress_tx {
995 let _ = tx.send(SseMessage::Error(SseErrorEvent {
996 message: err_msg.clone(),
997 }));
998 }
999 let _ = rejected.result_tx.send(Err(err_msg));
1000 state.queue.decrement();
1001 state.job_registry.remove(&job_id);
1002 break;
1003 };
1004
1005 worker.in_flight.fetch_add(1, Ordering::SeqCst);
1007 let pending = gpu_job.take().expect("gpu_job present in retry loop");
1008 match worker.job_tx.try_send(pending) {
1009 Ok(()) => {
1010 dispatched = true;
1011 }
1012 Err(std::sync::mpsc::TrySendError::Full(j)) => {
1013 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1014 gpu_job = Some(j);
1015 if preferred_gpu.is_none() {
1016 skip.push(worker.gpu.ordinal);
1017 if skip.len() >= state.gpu_pool.worker_count().max(1) {
1018 skip.clear();
1019 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1020 }
1021 } else {
1022 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1023 }
1024 }
1025 Err(std::sync::mpsc::TrySendError::Disconnected(j)) => {
1026 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1027 tracing::warn!(
1028 gpu = worker.gpu.ordinal,
1029 "GPU worker disconnected — retrying dispatch"
1030 );
1031 gpu_job = Some(j);
1032 if preferred_gpu.is_none() {
1033 skip.push(worker.gpu.ordinal);
1034 } else {
1035 let rejected = gpu_job.take().expect("gpu_job retained after disconnect");
1036 let err_msg = format!(
1037 "gpu:{} disconnected while dispatching model {model_name}",
1038 worker.gpu.ordinal
1039 );
1040 if let Some(tx) = rejected.progress_tx {
1041 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1042 message: err_msg.clone(),
1043 }));
1044 }
1045 let _ = rejected.result_tx.send(Err(err_msg));
1046 state.queue.decrement();
1047 state.job_registry.remove(&job_id);
1048 break;
1049 }
1050 }
1051 }
1052 }
1053 #[cfg(feature = "metrics")]
1054 crate::metrics::record_queue_depth(state.queue.pending());
1055 }
1056 tracing::info!("multi-GPU queue dispatcher shutting down");
1057}
1058
1059pub fn estimate_model_vram(model_name: &str) -> u64 {
1061 let lower = model_name.to_lowercase();
1064 if lower.contains("flux2")
1065 && lower.contains("9b")
1066 && (lower.contains(":bf16") || lower.contains(":fp16"))
1067 {
1068 32_000_000_000 } else if lower.contains(":q4") {
1070 6_000_000_000 } else if lower.contains(":q8") || lower.contains(":fp8") {
1072 12_000_000_000 } else if lower.contains(":bf16") || lower.contains(":fp16") {
1074 24_000_000_000 } else if lower.contains("sd15") || lower.contains("sd1.5") {
1076 4_000_000_000 } else {
1078 8_000_000_000
1080 }
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085 use super::*;
1086 use crate::gpu_pool::{GpuPool, GpuWorker};
1087 use crate::model_cache::ModelCache;
1088 use crate::state::QueueHandle;
1089 use mold_core::{GenerateRequest, ImageData, OutputFormat};
1090 use mold_db::MetadataDb;
1091 use mold_inference::device::DiscoveredGpu;
1092 use mold_inference::shared_pool::SharedPool;
1093 use std::sync::atomic::AtomicUsize;
1094 use std::sync::{Arc, Mutex, RwLock};
1095 use tempfile::TempDir;
1096
1097 fn fake_request(model: &str) -> GenerateRequest {
1100 GenerateRequest {
1101 prompt: "a cat".to_string(),
1102 negative_prompt: None,
1103 model: model.to_string(),
1104 width: 512,
1105 height: 512,
1106 steps: 4,
1107 guidance: 3.5,
1108 seed: Some(7),
1109 batch_size: 1,
1110 output_format: Some(OutputFormat::Png),
1111 embed_metadata: None,
1112 scheduler: None,
1113 cfg_plus: None,
1114 source_image: None,
1115 edit_images: None,
1116 strength: 0.75,
1117 mask_image: None,
1118 control_image: None,
1119 control_model: None,
1120 control_scale: 1.0,
1121 expand: None,
1122 original_prompt: None,
1123 lora: None,
1124 frames: None,
1125 fps: None,
1126 upscale_model: None,
1127 gif_preview: false,
1128 enable_audio: None,
1129 audio_file: None,
1130 audio_file_path: None,
1131 source_video: None,
1132 source_video_path: None,
1133 keyframes: None,
1134 pipeline: None,
1135 loras: None,
1136 retake_range: None,
1137 spatial_upscale: None,
1138 temporal_upscale: None,
1139 placement: None,
1140 }
1141 }
1142
1143 fn fake_image() -> ImageData {
1144 ImageData {
1145 data: vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
1148 format: OutputFormat::Png,
1149 width: 512,
1150 height: 512,
1151 index: 0,
1152 }
1153 }
1154
1155 fn test_worker(
1156 ordinal: usize,
1157 channel_size: usize,
1158 ) -> (
1159 Arc<GpuWorker>,
1160 std::sync::mpsc::Receiver<crate::gpu_pool::GpuJob>,
1161 ) {
1162 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(channel_size);
1163 let worker = Arc::new(GpuWorker {
1164 gpu: DiscoveredGpu {
1165 ordinal,
1166 name: format!("gpu{ordinal}"),
1167 total_vram_bytes: 24_000_000_000,
1168 free_vram_bytes: 24_000_000_000,
1169 },
1170 model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
1171 active_generation: Arc::new(RwLock::new(None)),
1172 model_load_lock: Arc::new(Mutex::new(())),
1173 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
1174 in_flight: AtomicUsize::new(0),
1175 consecutive_failures: AtomicUsize::new(0),
1176 degraded_until: RwLock::new(None),
1177 job_tx,
1178 });
1179 (worker, job_rx)
1180 }
1181
1182 #[test]
1183 fn save_image_to_dir_writes_file_and_creates_missing_dir() {
1184 let tmp = TempDir::new().unwrap();
1185 let nested = tmp.path().join("sub/output");
1186 assert!(!nested.exists());
1187
1188 save_image_to_dir(&nested, &fake_image(), "flux-dev:q4", 1, None, None, None);
1189
1190 assert!(nested.exists(), "save should mkdir -p");
1191 let entries: Vec<_> = std::fs::read_dir(&nested).unwrap().collect();
1192 assert_eq!(entries.len(), 1);
1193 let name = entries[0].as_ref().unwrap().file_name();
1194 let name_str = name.to_string_lossy();
1195 assert!(name_str.starts_with("mold-flux-dev-q4-"), "{name_str}");
1197 assert!(name_str.ends_with(".png"), "{name_str}");
1198 }
1199
1200 #[test]
1201 fn save_image_to_dir_includes_batch_index_when_batch_size_gt_1() {
1202 let tmp = TempDir::new().unwrap();
1203 let mut img = fake_image();
1204 img.index = 3;
1205 img.format = OutputFormat::Jpeg;
1206 img.data = vec![0xFF, 0xD8, 0xFF, 0xE0]; save_image_to_dir(tmp.path(), &img, "sdxl", 4, None, None, None);
1209
1210 let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1211 let name = entries[0]
1212 .as_ref()
1213 .unwrap()
1214 .file_name()
1215 .to_string_lossy()
1216 .to_string();
1217 assert!(
1218 name.contains("-3.jpeg"),
1219 "expected batch index suffix: {name}"
1220 );
1221 }
1222
1223 #[test]
1224 fn save_image_to_dir_upserts_metadata_row_when_db_provided() {
1225 let tmp = TempDir::new().unwrap();
1226 let db = MetadataDb::open_in_memory().unwrap();
1227 let req = fake_request("flux-dev:q4");
1228 let meta = OutputMetadata::from_generate_request(&req, 42, None, "test-version");
1229
1230 save_image_to_dir(
1231 tmp.path(),
1232 &fake_image(),
1233 "flux-dev:q4",
1234 1,
1235 Some(&meta),
1236 Some(1234),
1237 Some(&db),
1238 );
1239
1240 let rows = db.list(Some(tmp.path())).unwrap();
1241 assert_eq!(rows.len(), 1, "exactly one DB row for the saved file");
1242 let rec = &rows[0];
1243 assert_eq!(rec.metadata.prompt, "a cat");
1244 assert_eq!(rec.metadata.seed, 42);
1245 assert_eq!(rec.metadata.version, "test-version");
1246 assert_eq!(rec.format, OutputFormat::Png);
1247 assert_eq!(rec.generation_time_ms, Some(1234));
1248 assert!(rec.file_size_bytes.unwrap_or(0) > 0);
1250 }
1251
1252 #[test]
1253 fn save_image_to_dir_skips_db_when_metadata_is_none() {
1254 let tmp = TempDir::new().unwrap();
1255 let db = MetadataDb::open_in_memory().unwrap();
1256
1257 save_image_to_dir(
1258 tmp.path(),
1259 &fake_image(),
1260 "flux-dev:q4",
1261 1,
1262 None, Some(1234),
1264 Some(&db),
1265 );
1266
1267 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1);
1270 assert_eq!(db.list(None).unwrap().len(), 0);
1271 }
1272
1273 #[test]
1274 fn save_image_to_dir_invalid_path_does_not_panic() {
1275 save_image_to_dir(
1278 std::path::Path::new("/dev/null/cant-mkdir-here"),
1279 &fake_image(),
1280 "test",
1281 1,
1282 None,
1283 None,
1284 None,
1285 );
1286 }
1287
1288 #[test]
1289 fn save_video_to_dir_writes_mp4_and_records_metadata() {
1290 let tmp = TempDir::new().unwrap();
1291 let db = MetadataDb::open_in_memory().unwrap();
1292 let mut req = fake_request("ltx-video:fp16");
1293 req.frames = Some(25);
1294 req.fps = Some(24);
1295 let meta = OutputMetadata::from_generate_request(&req, 99, None, "test-version");
1296
1297 let bytes = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom".to_vec();
1300
1301 save_video_to_dir(
1302 tmp.path(),
1303 &bytes,
1304 b"",
1305 OutputFormat::Mp4,
1306 "ltx-video:fp16",
1307 &meta,
1308 Some(5000),
1309 Some(&db),
1310 );
1311
1312 let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1313 assert_eq!(entries.len(), 1);
1314 let name = entries[0]
1315 .as_ref()
1316 .unwrap()
1317 .file_name()
1318 .to_string_lossy()
1319 .to_string();
1320 assert!(name.starts_with("mold-ltx-video-fp16-"), "{name}");
1321 assert!(name.ends_with(".mp4"), "{name}");
1322
1323 let rows = db.list(Some(tmp.path())).unwrap();
1324 assert_eq!(rows.len(), 1);
1325 assert_eq!(rows[0].format, OutputFormat::Mp4);
1326 assert_eq!(rows[0].metadata.frames, Some(25));
1327 assert_eq!(rows[0].metadata.fps, Some(24));
1328 assert_eq!(rows[0].generation_time_ms, Some(5000));
1329 }
1330
1331 #[test]
1332 fn save_video_to_dir_without_db_still_writes_file() {
1333 let tmp = TempDir::new().unwrap();
1334 let req = fake_request("ltx-video:fp16");
1335 let meta = OutputMetadata::from_generate_request(&req, 1, None, "v");
1336
1337 save_video_to_dir(
1338 tmp.path(),
1339 b"fake gif bytes",
1340 b"",
1341 OutputFormat::Gif,
1342 "ltx-video:fp16",
1343 &meta,
1344 None,
1345 None,
1346 );
1347
1348 let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1349 assert_eq!(entries.len(), 1);
1350 let name = entries[0]
1351 .as_ref()
1352 .unwrap()
1353 .file_name()
1354 .to_string_lossy()
1355 .to_string();
1356 assert!(name.ends_with(".gif"), "{name}");
1357 }
1358
1359 #[test]
1360 fn save_video_to_dir_invalid_path_does_not_panic() {
1361 let req = fake_request("ltx-video:fp16");
1362 let meta = OutputMetadata::from_generate_request(&req, 1, None, "v");
1363 save_video_to_dir(
1364 std::path::Path::new("/dev/null/nope"),
1365 b"x",
1366 b"",
1367 OutputFormat::Mp4,
1368 "test",
1369 &meta,
1370 None,
1371 None,
1372 );
1373 }
1374
1375 #[test]
1382 fn save_video_preview_gif_writes_to_preview_cache() {
1383 let td = tempfile::tempdir().unwrap();
1384 let preview_dir = td.path().join("cache").join("previews");
1385
1386 const GIF: &[u8] = b"GIF89a\x01\x00\x01\x00\x00\x00\x00\x3b";
1387 save_video_preview_gif_to(&preview_dir, "ltx2-42.mp4", GIF);
1388
1389 let expected = preview_dir.join("ltx2-42.mp4.preview.gif");
1390 assert!(
1391 expected.is_file(),
1392 "preview gif should land at {}",
1393 expected.display()
1394 );
1395 assert_eq!(std::fs::read(&expected).unwrap(), GIF);
1396 }
1397
1398 #[test]
1399 fn build_sse_complete_event_video_carries_mp4_payload_and_metadata() {
1400 let video = mold_core::VideoData {
1407 data: vec![0x00, 0x00, 0x00, 0x18, b'f', b't', b'y', b'p'],
1408 format: OutputFormat::Mp4,
1409 width: 768,
1410 height: 512,
1411 frames: 25,
1412 fps: 24,
1413 thumbnail: vec![0x89, 0x50, 0x4E, 0x47],
1414 gif_preview: vec![b'G', b'I', b'F', b'8'],
1415 has_audio: true,
1416 duration_ms: Some(1040),
1417 audio_sample_rate: Some(44100),
1418 audio_channels: Some(2),
1419 };
1420 let resp = mold_core::GenerateResponse {
1421 images: vec![],
1422 video: Some(video.clone()),
1423 generation_time_ms: 1234,
1424 model: "ltx-2-19b-distilled:fp8".to_string(),
1425 seed_used: 7,
1426 gpu: Some(0),
1427 };
1428 let thumb_img = ImageData {
1431 data: video.thumbnail.clone(),
1432 format: OutputFormat::Png,
1433 width: video.width,
1434 height: video.height,
1435 index: 0,
1436 };
1437
1438 let event = build_sse_complete_event(&resp, &thumb_img);
1439
1440 let b64 = base64::engine::general_purpose::STANDARD;
1441 assert_eq!(event.image, b64.encode(&video.data));
1442 assert_eq!(event.format, OutputFormat::Mp4);
1443 assert_eq!(event.video_frames, Some(25));
1444 assert_eq!(event.video_fps, Some(24));
1445 assert_eq!(event.video_thumbnail, Some(b64.encode(&video.thumbnail)));
1446 assert_eq!(
1447 event.video_gif_preview,
1448 Some(b64.encode(&video.gif_preview))
1449 );
1450 assert!(event.video_has_audio);
1451 assert_eq!(event.video_duration_ms, Some(1040));
1452 assert_eq!(event.gpu, Some(0));
1453 }
1454
1455 #[test]
1456 fn build_sse_complete_event_video_empty_gif_preview_omits_field() {
1457 let video = mold_core::VideoData {
1458 data: vec![0x00, 0x00, 0x00, 0x18],
1459 format: OutputFormat::Mp4,
1460 width: 256,
1461 height: 256,
1462 frames: 17,
1463 fps: 12,
1464 thumbnail: vec![0x89, 0x50],
1465 gif_preview: Vec::new(),
1466 has_audio: false,
1467 duration_ms: None,
1468 audio_sample_rate: None,
1469 audio_channels: None,
1470 };
1471 let resp = mold_core::GenerateResponse {
1472 images: vec![],
1473 video: Some(video),
1474 generation_time_ms: 0,
1475 model: "m".to_string(),
1476 seed_used: 0,
1477 gpu: None,
1478 };
1479 let event = build_sse_complete_event(&resp, &fake_image());
1480 assert!(event.video_gif_preview.is_none());
1481 assert!(!event.video_has_audio);
1482 }
1483
1484 #[test]
1485 fn build_sse_complete_event_image_clears_all_video_fields() {
1486 let resp = mold_core::GenerateResponse {
1487 images: vec![fake_image()],
1488 video: None,
1489 generation_time_ms: 100,
1490 model: "flux-schnell:q8".to_string(),
1491 seed_used: 5,
1492 gpu: None,
1493 };
1494 let event = build_sse_complete_event(&resp, &fake_image());
1495 assert_eq!(event.format, OutputFormat::Png);
1496 assert!(event.video_frames.is_none());
1497 assert!(event.video_fps.is_none());
1498 assert!(event.video_thumbnail.is_none());
1499 assert!(event.video_gif_preview.is_none());
1500 assert!(!event.video_has_audio);
1501 assert!(event.video_duration_ms.is_none());
1502 }
1503
1504 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1505 async fn queue_dispatcher_waits_for_worker_capacity_instead_of_rejecting() {
1506 let (worker, worker_rx) = test_worker(0, 1);
1507 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
1508 let queue = QueueHandle::new(job_tx.clone());
1509 let state = crate::state::AppState::empty(
1510 mold_core::Config::default(),
1511 queue.clone(),
1512 Arc::new(GpuPool {
1513 workers: vec![worker.clone()],
1514 }),
1515 8,
1516 );
1517
1518 let (filler_result_tx, _filler_result_rx) = tokio::sync::oneshot::channel();
1519 let filler_job = crate::gpu_pool::GpuJob {
1520 id: String::new(),
1521 model: "busy-model".to_string(),
1522 request: fake_request("busy-model"),
1523 progress_tx: None,
1524 result_tx: filler_result_tx,
1525 output_dir: None,
1526 config: state.config.clone(),
1527 metadata_db: state.metadata_db.clone(),
1528 queue: state.queue.clone(),
1529 registry: state.job_registry.clone(),
1530 };
1531 worker.job_tx.send(filler_job).unwrap();
1532
1533 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
1534
1535 let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
1536 let job = crate::state::GenerationJob {
1537 id: String::new(),
1538 request: fake_request("flux-dev:q4"),
1539 progress_tx: None,
1540 result_tx,
1541 output_dir: None,
1542 };
1543 let _position = queue.submit(job, 8).await.unwrap();
1544
1545 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1546 assert!(
1547 result_rx.try_recv().is_err(),
1548 "dispatcher should keep the job pending while all worker channels are full"
1549 );
1550
1551 let _filler = worker_rx
1552 .recv()
1553 .expect("filler job should occupy the local channel");
1554 let dispatched = worker_rx
1555 .recv_timeout(std::time::Duration::from_secs(1))
1556 .expect("queued job should dispatch once capacity is available");
1557 assert_eq!(dispatched.model, "flux-dev:q4");
1558
1559 drop(job_tx);
1560 dispatcher.abort();
1561 }
1562
1563 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1564 async fn queue_dispatcher_waits_for_degraded_worker_recovery_instead_of_rejecting() {
1565 let (worker, worker_rx) = test_worker(0, 1);
1566 worker.consecutive_failures.store(3, Ordering::SeqCst);
1567 *worker.degraded_until.write().unwrap() =
1568 Some(Instant::now() + std::time::Duration::from_secs(60));
1569
1570 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
1571 let queue = QueueHandle::new(job_tx.clone());
1572 let state = crate::state::AppState::empty(
1573 mold_core::Config::default(),
1574 queue.clone(),
1575 Arc::new(GpuPool {
1576 workers: vec![worker.clone()],
1577 }),
1578 8,
1579 );
1580 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
1581
1582 let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
1583 let job = crate::state::GenerationJob {
1584 id: String::new(),
1585 request: fake_request("flux-dev:q4"),
1586 progress_tx: None,
1587 result_tx,
1588 output_dir: None,
1589 };
1590 queue.submit(job, 8).await.unwrap();
1591
1592 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1593 assert!(
1594 result_rx.try_recv().is_err(),
1595 "dispatcher should keep the job pending while all workers are degraded"
1596 );
1597 assert!(
1598 worker_rx.try_recv().is_err(),
1599 "degraded worker must not receive work before recovery"
1600 );
1601
1602 worker.consecutive_failures.store(0, Ordering::SeqCst);
1603 *worker.degraded_until.write().unwrap() = None;
1604
1605 let dispatched = worker_rx
1606 .recv_timeout(std::time::Duration::from_secs(1))
1607 .expect("queued job should dispatch once a worker recovers");
1608 assert_eq!(dispatched.model, "flux-dev:q4");
1609
1610 drop(job_tx);
1611 dispatcher.abort();
1612 }
1613
1614 #[tokio::test]
1621 async fn cache_take_on_vanished_engine_returns_none_not_panic() {
1622 use crate::model_cache::ModelCache;
1623 use mold_core::GenerateResponse;
1624 use mold_inference::InferenceEngine;
1625
1626 struct StubEngine(&'static str);
1627 impl InferenceEngine for StubEngine {
1628 fn generate(&mut self, _r: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1629 unimplemented!()
1630 }
1631 fn model_name(&self) -> &str {
1632 self.0
1633 }
1634 fn is_loaded(&self) -> bool {
1635 true
1636 }
1637 fn load(&mut self) -> anyhow::Result<()> {
1638 Ok(())
1639 }
1640 }
1641
1642 let mut cache = ModelCache::new(3);
1643 assert!(cache.take("vanished-model").is_none());
1646
1647 cache.insert(Box::new(StubEngine("present-model")), 0);
1651 let first = cache.take("present-model");
1652 assert!(first.is_some());
1653 assert!(
1654 cache.take("present-model").is_none(),
1655 "double-take must return None"
1656 );
1657 }
1658
1659 fn buf_job(model: &str) -> BufferedJob {
1660 let (tx, _rx) = tokio::sync::oneshot::channel();
1661 BufferedJob::new(crate::state::GenerationJob {
1662 id: String::new(),
1663 request: fake_request(model),
1664 progress_tx: None,
1665 result_tx: tx,
1666 output_dir: None,
1667 })
1668 }
1669
1670 #[test]
1671 fn pick_next_job_picks_head_when_head_model_loaded() {
1672 use std::collections::{HashSet, VecDeque};
1673 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
1674 buffer.push_back(buf_job("a"));
1675 buffer.push_back(buf_job("b"));
1676 buffer.push_back(buf_job("a"));
1677 let loaded: HashSet<String> = ["a".to_string()].into_iter().collect();
1678 let picked = pick_next_job(&mut buffer, &loaded, 3);
1679 assert_eq!(picked.request.model, "a");
1680 assert_eq!(buffer.len(), 2);
1681 assert_eq!(buffer.front().unwrap().job.request.model, "b");
1682 assert_eq!(
1683 buffer.front().unwrap().deferred,
1684 0,
1685 "head shouldn't be deferred when picker chose the head itself"
1686 );
1687 }
1688
1689 #[test]
1690 fn pick_next_job_picks_non_head_when_only_non_head_model_loaded() {
1691 use std::collections::{HashSet, VecDeque};
1692 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
1693 buffer.push_back(buf_job("a"));
1694 buffer.push_back(buf_job("b"));
1695 buffer.push_back(buf_job("a"));
1696 let loaded: HashSet<String> = ["b".to_string()].into_iter().collect();
1697 let picked = pick_next_job(&mut buffer, &loaded, 3);
1698 assert_eq!(picked.request.model, "b");
1699 assert_eq!(buffer.len(), 2);
1700 assert_eq!(buffer.front().unwrap().job.request.model, "a");
1702 assert_eq!(buffer.front().unwrap().deferred, 1);
1703 }
1704
1705 #[test]
1706 fn pick_next_job_force_dispatches_head_after_max_deferrals() {
1707 use std::collections::{HashSet, VecDeque};
1708 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
1709 let mut head = buf_job("a");
1710 head.deferred = 3;
1711 buffer.push_back(head);
1712 buffer.push_back(buf_job("b"));
1713 let loaded: HashSet<String> = ["b".to_string()].into_iter().collect();
1715 let picked = pick_next_job(&mut buffer, &loaded, 3);
1716 assert_eq!(picked.request.model, "a");
1717 assert_eq!(buffer.len(), 1);
1718 assert_eq!(buffer.front().unwrap().job.request.model, "b");
1719 }
1720
1721 #[test]
1722 fn pick_next_job_falls_back_to_head_when_nothing_loaded() {
1723 use std::collections::{HashSet, VecDeque};
1724 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
1725 buffer.push_back(buf_job("a"));
1726 buffer.push_back(buf_job("b"));
1727 let loaded: HashSet<String> = HashSet::new();
1728 let picked = pick_next_job(&mut buffer, &loaded, 3);
1729 assert_eq!(picked.request.model, "a");
1730 }
1731
1732 #[test]
1736 fn pick_next_job_max_deferrals_zero_picks_head_even_when_non_head_loaded() {
1737 use std::collections::{HashSet, VecDeque};
1738 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
1739 buffer.push_back(buf_job("b")); buffer.push_back(buf_job("a")); let loaded: HashSet<String> = ["a".to_string()].into_iter().collect();
1742 let picked = pick_next_job(&mut buffer, &loaded, 0);
1743 assert_eq!(
1744 picked.request.model, "b",
1745 "max_deferrals=0 must force FIFO — head must win even when only the non-head model is loaded"
1746 );
1747 assert_eq!(buffer.len(), 1);
1748 assert_eq!(buffer.front().unwrap().job.request.model, "a");
1749 }
1750
1751 #[test]
1755 fn pick_next_job_max_deferrals_zero_with_empty_loaded_picks_head() {
1756 use std::collections::{HashSet, VecDeque};
1757 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
1758 buffer.push_back(buf_job("a")); buffer.push_back(buf_job("b"));
1760 let loaded: HashSet<String> = HashSet::new();
1761 let picked = pick_next_job(&mut buffer, &loaded, 0);
1762 assert_eq!(picked.request.model, "a");
1763 assert_eq!(buffer.len(), 1);
1764 assert_eq!(buffer.front().unwrap().job.request.model, "b");
1765 }
1766
1767 #[test]
1772 fn pick_next_job_picks_front_most_match_when_multiple_loaded() {
1773 use std::collections::{HashSet, VecDeque};
1774 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
1775 buffer.push_back(buf_job("a"));
1776 buffer.push_back(buf_job("b"));
1777 buffer.push_back(buf_job("a"));
1778 buffer.push_back(buf_job("b"));
1779 let loaded: HashSet<String> = ["a".to_string(), "b".to_string()].into_iter().collect();
1780 let picked = pick_next_job(&mut buffer, &loaded, 3);
1781 assert_eq!(
1782 picked.request.model, "a",
1783 "front-most match wins (the first `a`), not the loaded model with the most copies later in the buffer"
1784 );
1785 assert_eq!(buffer.len(), 3);
1789 let remaining: Vec<&str> = buffer
1790 .iter()
1791 .map(|b| b.job.request.model.as_str())
1792 .collect();
1793 assert_eq!(remaining, vec!["b", "a", "b"]);
1794 assert_eq!(buffer.front().unwrap().deferred, 0);
1795 }
1796
1797 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1801 async fn queue_dispatcher_reorders_interleaved_jobs_to_minimize_swaps() {
1802 let (worker, worker_rx) = test_worker(0, 8);
1803 {
1806 let mut cache = worker.model_cache.lock().unwrap();
1807 struct Engine(&'static str);
1808 impl mold_inference::InferenceEngine for Engine {
1809 fn generate(
1810 &mut self,
1811 _r: &GenerateRequest,
1812 ) -> anyhow::Result<mold_core::GenerateResponse> {
1813 unimplemented!()
1814 }
1815 fn model_name(&self) -> &str {
1816 self.0
1817 }
1818 fn is_loaded(&self) -> bool {
1819 true
1820 }
1821 fn load(&mut self) -> anyhow::Result<()> {
1822 Ok(())
1823 }
1824 }
1825 cache.insert(Box::new(Engine("a")), 0);
1826 }
1827
1828 let (job_tx, job_rx) = tokio::sync::mpsc::channel(8);
1829 let queue = QueueHandle::new(job_tx.clone());
1830 let state = crate::state::AppState::empty(
1831 mold_core::Config::default(),
1832 queue.clone(),
1833 Arc::new(GpuPool {
1834 workers: vec![worker.clone()],
1835 }),
1836 8,
1837 );
1838
1839 let mut result_rxs = Vec::new();
1842 for model in ["a", "b", "a", "b"] {
1843 let (tx, rx) = tokio::sync::oneshot::channel();
1844 let job = crate::state::GenerationJob {
1845 id: String::new(),
1846 request: fake_request(model),
1847 progress_tx: None,
1848 result_tx: tx,
1849 output_dir: None,
1850 };
1851 queue.submit(job, 8).await.unwrap();
1852 result_rxs.push(rx);
1853 }
1854
1855 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
1856
1857 let mut order = Vec::new();
1858 for _ in 0..4 {
1859 let dispatched = worker_rx
1860 .recv_timeout(std::time::Duration::from_secs(2))
1861 .expect("worker should receive the dispatched job");
1862 order.push(dispatched.model);
1863 }
1864 drop(job_tx);
1865 dispatcher.abort();
1866
1867 assert_eq!(
1868 order,
1869 vec![
1870 "a".to_string(),
1871 "a".to_string(),
1872 "b".to_string(),
1873 "b".to_string(),
1874 ],
1875 "lookahead reorder should batch all `a` jobs together before swapping to `b`"
1876 );
1877 }
1878
1879 #[tokio::test]
1886 async fn top_up_buffer_never_exceeds_capacity() {
1887 use std::collections::VecDeque;
1888 let (job_tx, mut job_rx) = tokio::sync::mpsc::channel::<GenerationJob>(32);
1889
1890 for i in 0..10 {
1893 let (tx, _rx) = tokio::sync::oneshot::channel();
1894 let job = GenerationJob {
1895 id: String::new(),
1896 request: fake_request(&format!("model-{i}")),
1897 progress_tx: None,
1898 result_tx: tx,
1899 output_dir: None,
1900 };
1901 job_tx.send(job).await.unwrap();
1902 }
1903
1904 let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(4);
1906 top_up_buffer(&mut buffer, &mut job_rx, 4);
1907 assert_eq!(
1908 buffer.len(),
1909 4,
1910 "top_up_buffer must cap at buffer_size, leaving the rest in the channel"
1911 );
1912
1913 while buffer.pop_front().is_some() {}
1916 top_up_buffer(&mut buffer, &mut job_rx, 4);
1917 assert_eq!(buffer.len(), 4);
1918 let names: Vec<&str> = buffer
1919 .iter()
1920 .map(|b| b.job.request.model.as_str())
1921 .collect();
1922 assert_eq!(
1923 names,
1924 vec!["model-4", "model-5", "model-6", "model-7"],
1925 "second top-up must drain the next FIFO window from the channel"
1926 );
1927
1928 drop(job_tx);
1931 while buffer.pop_front().is_some() {}
1932 top_up_buffer(&mut buffer, &mut job_rx, 4);
1933 assert_eq!(
1934 buffer.len(),
1935 2,
1936 "top_up_buffer drains the channel tail when fewer jobs than capacity remain"
1937 );
1938 let names: Vec<&str> = buffer
1939 .iter()
1940 .map(|b| b.job.request.model.as_str())
1941 .collect();
1942 assert_eq!(names, vec!["model-8", "model-9"]);
1943 }
1944
1945 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1951 async fn queue_dispatcher_dispatches_all_jobs_when_submission_exceeds_buffer() {
1952 let (worker, worker_rx) = test_worker(0, 4);
1953 let (job_tx, job_rx) = tokio::sync::mpsc::channel(32);
1954 let queue = QueueHandle::new(job_tx.clone());
1955 let state = crate::state::AppState::empty(
1956 mold_core::Config::default(),
1957 queue.clone(),
1958 Arc::new(GpuPool {
1959 workers: vec![worker.clone()],
1960 }),
1961 32,
1962 );
1963
1964 let drain_worker = worker.clone();
1970 let drainer = std::thread::spawn(move || {
1971 let mut order = Vec::new();
1972 while order.len() < 10 {
1973 match worker_rx.recv_timeout(std::time::Duration::from_secs(5)) {
1974 Ok(j) => {
1975 drain_worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1976 order.push(j.model);
1977 }
1978 Err(e) => panic!("drain stalled at {:?}: {e:?}", order),
1979 }
1980 }
1981 order
1982 });
1983
1984 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
1985
1986 let mut held_rxs = Vec::new();
1992 for i in 0..10 {
1993 let (tx, rx) = tokio::sync::oneshot::channel();
1994 held_rxs.push(rx);
1995 let job = crate::state::GenerationJob {
1996 id: String::new(),
1997 request: fake_request(&format!("model-{i}")),
1998 progress_tx: None,
1999 result_tx: tx,
2000 output_dir: None,
2001 };
2002 queue.submit(job, 32).await.unwrap();
2003 }
2004
2005 let order = drainer.join().expect("drainer thread panic");
2006 drop(job_tx);
2007 dispatcher.abort();
2008
2009 let expected: Vec<String> = (0..10).map(|i| format!("model-{i}")).collect();
2010 assert_eq!(
2011 order, expected,
2012 "10 distinct jobs must come out in FIFO across buffer rotations"
2013 );
2014 }
2015
2016 static QUEUE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2018
2019 fn with_queue_env<R>(name: &str, value: Option<&str>, f: impl FnOnce() -> R) -> R {
2020 let _g = QUEUE_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2021 let prev = std::env::var(name).ok();
2022 match value {
2023 Some(v) => std::env::set_var(name, v),
2024 None => std::env::remove_var(name),
2025 }
2026 let out = f();
2027 match prev {
2028 Some(v) => std::env::set_var(name, v),
2029 None => std::env::remove_var(name),
2030 }
2031 out
2032 }
2033
2034 #[test]
2035 fn resolve_lookahead_buffer_uses_default_when_env_missing() {
2036 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, None, resolve_lookahead_buffer);
2037 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2038 }
2039
2040 #[test]
2041 fn resolve_lookahead_buffer_honors_env_within_range() {
2042 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("4"), resolve_lookahead_buffer);
2043 assert_eq!(n, 4);
2044 }
2045
2046 #[test]
2047 fn resolve_lookahead_buffer_falls_back_when_out_of_range() {
2048 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("0"), resolve_lookahead_buffer);
2050 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2051 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("999"), resolve_lookahead_buffer);
2052 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2053 }
2054
2055 #[test]
2056 fn resolve_lookahead_buffer_falls_back_when_unparseable() {
2057 let n = with_queue_env(
2058 LOOKAHEAD_BUFFER_ENV,
2059 Some("not-a-number"),
2060 resolve_lookahead_buffer,
2061 );
2062 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2063 }
2064
2065 #[test]
2066 fn resolve_max_deferrals_uses_default_when_env_missing() {
2067 let n = with_queue_env(MAX_DEFERRALS_ENV, None, resolve_max_deferrals);
2068 assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2069 }
2070
2071 #[test]
2072 fn resolve_max_deferrals_honors_env_within_range() {
2073 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("0"), resolve_max_deferrals);
2075 assert_eq!(n, 0);
2076 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("32"), resolve_max_deferrals);
2077 assert_eq!(n, 32);
2078 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("5"), resolve_max_deferrals);
2079 assert_eq!(n, 5);
2080 }
2081
2082 #[test]
2083 fn resolve_max_deferrals_falls_back_when_out_of_range() {
2084 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("999"), resolve_max_deferrals);
2085 assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2086 }
2087
2088 #[test]
2089 fn resolve_max_deferrals_falls_back_when_unparseable() {
2090 let n = with_queue_env(
2091 MAX_DEFERRALS_ENV,
2092 Some("not-a-number"),
2093 resolve_max_deferrals,
2094 );
2095 assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2096 }
2097
2098 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2099 async fn queue_dispatcher_honors_explicit_placement_gpu() {
2100 let (worker0, rx0) = test_worker(0, 1);
2101 let (worker1, rx1) = test_worker(1, 1);
2102 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2103 let queue = QueueHandle::new(job_tx.clone());
2104 let state = crate::state::AppState::empty(
2105 mold_core::Config::default(),
2106 queue.clone(),
2107 Arc::new(GpuPool {
2108 workers: vec![worker0, worker1],
2109 }),
2110 8,
2111 );
2112
2113 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state));
2114
2115 let mut request = fake_request("flux-dev:q4");
2116 request.placement = Some(mold_core::types::DevicePlacement {
2117 text_encoders: mold_core::types::DeviceRef::Auto,
2118 advanced: Some(mold_core::types::AdvancedPlacement {
2119 transformer: mold_core::types::DeviceRef::gpu(1),
2120 ..mold_core::types::AdvancedPlacement::default()
2121 }),
2122 });
2123
2124 let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
2125 let job = crate::state::GenerationJob {
2126 id: String::new(),
2127 request,
2128 progress_tx: None,
2129 result_tx,
2130 output_dir: None,
2131 };
2132 let _position = queue.submit(job, 8).await.unwrap();
2133
2134 let dispatched = rx1
2135 .recv_timeout(std::time::Duration::from_secs(1))
2136 .expect("explicit placement should route to gpu 1");
2137 assert_eq!(dispatched.model, "flux-dev:q4");
2138 assert!(rx0.try_recv().is_err(), "gpu 0 should not receive the job");
2139
2140 drop(job_tx);
2141 dispatcher.abort();
2142 }
2143}