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::{MetadataDb, RecordSource};
9use sha2::{Digest, Sha256};
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::time::Instant;
12use tokio::sync::Notify;
13
14use crate::gpu_pool::GpuJob;
15use crate::model_manager;
16use crate::state::{
17 ActiveGenerationSnapshot, AppState, GenerationJob, GenerationJobResult, SseMessage,
18};
19
20fn progress_to_sse(event: mold_inference::ProgressEvent) -> SseProgressEvent {
22 event.into()
23}
24
25pub(crate) fn clean_error_message(e: &anyhow::Error) -> String {
31 let full = format!("{e:#}");
32 let mut lines: Vec<&str> = Vec::new();
33 for line in full.lines() {
34 let trimmed = line.trim_start();
35 if (trimmed.starts_with("0:") || trimmed.starts_with("1:"))
36 && trimmed.len() > 3
37 && trimmed
38 .as_bytes()
39 .first()
40 .is_some_and(|b| b.is_ascii_digit())
41 {
42 break;
43 }
44 if trimmed.len() > 2
45 && trimmed.as_bytes()[0].is_ascii_digit()
46 && trimmed.contains("::")
47 && trimmed.contains("at ")
48 {
49 break;
50 }
51 lines.push(line);
52 }
53 let msg = lines.join("\n").trim().to_string();
54 if msg.is_empty() {
55 format!("{}", e.root_cause())
56 } else {
57 msg
58 }
59}
60
61fn set_active_generation(state: &AppState, model: &str, prompt: &str) {
62 let prompt_sha256 = format!("{:x}", Sha256::digest(prompt.as_bytes()));
63 let started_at_unix_ms = mold_core::time::now_epoch_ms_u64();
64
65 let mut active = state
66 .active_generation
67 .write()
68 .unwrap_or_else(|e| e.into_inner());
69 *active = Some(ActiveGenerationSnapshot {
70 model: model.to_string(),
71 prompt_sha256,
72 started_at_unix_ms,
73 started_at: Instant::now(),
74 });
75}
76
77fn clear_active_generation(state: &AppState) {
78 let mut active = state
79 .active_generation
80 .write()
81 .unwrap_or_else(|e| e.into_inner());
82 *active = None;
83}
84
85#[allow(clippy::too_many_arguments)]
95pub(crate) fn save_image_to_dir(
96 dir: &std::path::Path,
97 img: &mold_core::ImageData,
98 model: &str,
99 batch_size: u32,
100 metadata: Option<&OutputMetadata>,
101 generation_time_ms: Option<i64>,
102 db: Option<&MetadataDb>,
103 events: Option<&crate::events::EventBroadcaster>,
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 timestamp_ms = mold_core::time::now_epoch_ms_u64();
110 let ext = img.format.to_string();
111 let filename =
112 mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
113 let path = dir.join(&filename);
114 match std::fs::write(&path, &img.data) {
115 Ok(()) => tracing::info!("saved image to {}", path.display()),
116 Err(e) => {
117 tracing::warn!("failed to save image to {}: {e}", path.display());
118 return;
119 }
120 }
121 let mut image_row = None;
122 if let (Some(db), Some(meta)) = (db, metadata) {
123 image_row = mold_db::persist::record_saved_output_returning(
124 db,
125 dir,
126 &filename,
127 &path,
128 &mold_db::persist::OutputRecordParams {
129 format: img.format,
130 metadata: meta,
131 source: RecordSource::Server,
132 generation_time_ms,
133 backend: Some(mold_inference::compiled_backend_label()),
134 },
135 )
136 .map(|rec| Box::new(rec.to_gallery_image()));
137 }
138 if let Some(events) = events {
141 events.publish(mold_core::ServerEvent::GalleryAdded {
142 filename,
143 image: image_row,
144 });
145 }
146}
147
148#[allow(clippy::too_many_arguments)]
158pub(crate) fn save_video_to_dir(
159 dir: &std::path::Path,
160 bytes: &[u8],
161 gif_preview: &[u8],
162 format: OutputFormat,
163 model: &str,
164 metadata: &OutputMetadata,
165 generation_time_ms: Option<i64>,
166 db: Option<&MetadataDb>,
167 events: Option<&crate::events::EventBroadcaster>,
168) {
169 if let Err(e) = std::fs::create_dir_all(dir) {
170 tracing::warn!("failed to create output dir {}: {e}", dir.display());
171 return;
172 }
173 let ts = mold_core::time::now_epoch_ms_u64();
174 let ext = format.extension();
175 let filename = mold_core::default_output_filename(model, ts, ext, 1, 0);
176 let path = dir.join(&filename);
177 if let Err(e) = std::fs::write(&path, bytes) {
178 tracing::error!("failed to save video to {}: {e}", path.display());
179 return;
180 }
181 if !gif_preview.is_empty() {
182 save_video_preview_gif(&filename, gif_preview);
183 }
184 let mut image_row = None;
185 if let Some(db) = db {
186 image_row = mold_db::persist::record_saved_output_returning(
187 db,
188 dir,
189 &filename,
190 &path,
191 &mold_db::persist::OutputRecordParams {
192 format,
193 metadata,
194 source: RecordSource::Server,
195 generation_time_ms,
196 backend: Some(mold_inference::compiled_backend_label()),
197 },
198 )
199 .map(|rec| Box::new(rec.to_gallery_image()));
200 }
201 if let Some(events) = events {
202 events.publish(mold_core::ServerEvent::GalleryAdded {
203 filename,
204 image: image_row,
205 });
206 }
207}
208
209fn requested_post_upscale_model(req: &mold_core::GenerateRequest) -> Option<&str> {
210 req.upscale_model
211 .as_deref()
212 .map(str::trim)
213 .filter(|m| !m.is_empty())
214}
215
216pub(crate) fn apply_output_dimensions_to_metadata(metadata: &mut OutputMetadata, img: &ImageData) {
217 metadata.apply_output_dimensions(img.width, img.height);
218}
219
220pub(crate) fn apply_upscale_response_to_image_generation(
221 req: &mold_core::GenerateRequest,
222 response: &mut mold_core::GenerateResponse,
223 original: ImageData,
224 upscaled: mold_core::UpscaleResponse,
225) -> anyhow::Result<ImageData> {
226 if response.video.is_some() || requested_post_upscale_model(req).is_none() {
227 return Ok(original);
228 }
229 if upscaled.image.data.is_empty() {
230 anyhow::bail!("upscaler returned an empty image");
231 }
232 response.generation_time_ms = response
233 .generation_time_ms
234 .saturating_add(upscaled.upscale_time_ms);
235 Ok(ImageData {
236 index: original.index,
237 ..upscaled.image
238 })
239}
240
241async fn upscale_generated_image_on_single_worker(
242 state: &AppState,
243 req: &mold_core::GenerateRequest,
244 img: ImageData,
245 progress_tx: Option<&tokio::sync::mpsc::UnboundedSender<SseMessage>>,
246) -> Result<ImageData, String> {
247 let Some(upscale_model) = requested_post_upscale_model(req).map(str::to_string) else {
248 return Ok(img);
249 };
250 let model_name = mold_core::manifest::resolve_model_name(&upscale_model);
251 if let Some(tx) = progress_tx {
252 let _ = tx.send(SseMessage::Progress(SseProgressEvent::StageStart {
253 name: format!("Loading upscaler {model_name}"),
254 }));
255 }
256
257 let needs_pull = {
258 let config = state.config.read().await;
259 config
260 .models
261 .get(&model_name)
262 .and_then(|c| c.transformer.as_ref())
263 .is_none()
264 };
265 if needs_pull {
266 if mold_core::manifest::find_manifest(&model_name).is_none() {
267 return Err(format!("unknown upscaler model '{model_name}'"));
268 }
269 model_manager::pull_model(state, &model_name, None)
270 .await
271 .map_err(|e| format!("failed to pull upscaler model: {}", e.error))?;
272 }
273
274 let weights_path = {
275 let config = state.config.read().await;
276 config
277 .models
278 .get(&model_name)
279 .and_then(|c| c.transformer.as_ref())
280 .map(std::path::PathBuf::from)
281 }
282 .ok_or_else(|| format!("upscaler model '{model_name}' not configured after pull"))?;
283
284 let upscale_req = mold_core::UpscaleRequest {
285 model: model_name.clone(),
286 image: img.data.clone(),
287 output_format: img.format,
288 tile_size: None,
289 };
290 let upscaler_cache = state.upscaler_cache.clone();
291 let progress_tx_for_blocking = progress_tx.cloned();
292 let upscaled =
293 tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
294 let mut cache = upscaler_cache.lock().unwrap_or_else(|e| e.into_inner());
295 let needs_new = cache.as_ref().is_none_or(|e| e.model_name() != model_name);
296 if needs_new {
297 let new_engine = mold_inference::create_upscale_engine(
298 model_name.clone(),
299 weights_path,
300 mold_inference::LoadStrategy::Eager,
301 0,
302 )?;
303 *cache = Some(new_engine);
304 }
305 let engine = cache.as_mut().unwrap();
306 if let Some(tx) = progress_tx_for_blocking {
307 engine.set_on_progress(Box::new(move |event| {
308 let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
309 }));
310 }
311 let result = engine.upscale(&upscale_req);
312 engine.clear_on_progress();
313 result
314 })
315 .await
316 .map_err(|e| format!("upscale task failed: {e}"))?
317 .map_err(|e| format!("upscale failed: {e}"))?;
318
319 let mut response = mold_core::GenerateResponse {
320 images: vec![],
321 video: None,
322 generation_time_ms: 0,
323 model: req.model.clone(),
324 seed_used: req.seed.unwrap_or(0),
325 gpu: None,
326 };
327 apply_upscale_response_to_image_generation(req, &mut response, img, upscaled)
328 .map_err(|e| format!("upscale failed: {e}"))
329}
330
331pub(crate) fn save_video_preview_gif(filename: &str, gif_bytes: &[u8]) {
340 let preview_dir = mold_core::Config::mold_dir()
341 .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
342 .join("cache")
343 .join("previews");
344 save_video_preview_gif_to(&preview_dir, filename, gif_bytes);
345}
346
347fn save_video_preview_gif_to(preview_dir: &std::path::Path, filename: &str, gif_bytes: &[u8]) {
351 if let Err(e) = std::fs::create_dir_all(preview_dir) {
352 tracing::warn!(
353 "failed to create preview cache dir {}: {e}",
354 preview_dir.display()
355 );
356 return;
357 }
358 let preview_path = preview_dir.join(mold_core::media_paths::preview_gif_filename(filename));
359 if let Err(e) = std::fs::write(&preview_path, gif_bytes) {
360 tracing::warn!(
361 "failed to write preview gif {}: {e}",
362 preview_path.display()
363 );
364 }
365}
366
367pub(crate) fn build_sse_complete_event(
384 response: &mold_core::GenerateResponse,
385 img: &mold_core::ImageData,
386) -> SseCompleteEvent {
387 let b64 = base64::engine::general_purpose::STANDARD;
388 if let Some(ref video) = response.video {
389 SseCompleteEvent {
390 image: b64.encode(&video.data),
391 format: video.format,
392 width: video.width,
393 height: video.height,
394 seed_used: response.seed_used,
395 generation_time_ms: response.generation_time_ms,
396 model: response.model.clone(),
397 video_frames: Some(video.frames),
398 video_fps: Some(video.fps),
399 video_thumbnail: Some(b64.encode(&video.thumbnail)),
400 video_gif_preview: if video.gif_preview.is_empty() {
401 None
402 } else {
403 Some(b64.encode(&video.gif_preview))
404 },
405 video_has_audio: video.has_audio,
406 video_duration_ms: video.duration_ms,
407 video_audio_sample_rate: video.audio_sample_rate,
408 video_audio_channels: video.audio_channels,
409 gpu: response.gpu,
410 }
411 } else {
412 SseCompleteEvent {
413 image: b64.encode(&img.data),
414 format: img.format,
415 width: img.width,
416 height: img.height,
417 seed_used: response.seed_used,
418 generation_time_ms: response.generation_time_ms,
419 model: response.model.clone(),
420 video_frames: None,
421 video_fps: None,
422 video_thumbnail: None,
423 video_gif_preview: None,
424 video_has_audio: false,
425 video_duration_ms: None,
426 video_audio_sample_rate: None,
427 video_audio_channels: None,
428 gpu: response.gpu,
429 }
430 }
431}
432
433pub struct QueuePause {
439 paused: AtomicBool,
440 notify: Notify,
444}
445
446impl QueuePause {
447 pub fn new() -> Arc<Self> {
448 Arc::new(Self {
449 paused: AtomicBool::new(false),
450 notify: Notify::new(),
451 })
452 }
453
454 pub fn pause(&self) -> bool {
458 !self.paused.swap(true, Ordering::SeqCst)
459 }
460
461 pub fn resume(&self) -> bool {
464 let was_paused = self.paused.swap(false, Ordering::SeqCst);
465 if was_paused {
466 self.notify.notify_waiters();
467 }
468 was_paused
469 }
470
471 pub fn is_paused(&self) -> bool {
472 self.paused.load(Ordering::SeqCst)
473 }
474
475 pub async fn wait_if_paused(&self) {
481 while self.paused.load(Ordering::SeqCst) {
482 let notified = self.notify.notified();
483 tokio::pin!(notified);
484 notified.as_mut().enable();
485 if !self.paused.load(Ordering::SeqCst) {
486 break;
487 }
488 notified.await;
489 }
490 }
491}
492
493pub async fn run_queue_worker(
498 mut job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
499 state: AppState,
500) {
501 tracing::debug!("generation queue worker started");
502 let buffer_size = resolve_lookahead_buffer();
503 let max_deferrals = resolve_max_deferrals();
504 let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(buffer_size);
505
506 loop {
507 state.queue_pause.wait_if_paused().await;
510 if buffer.is_empty() {
511 match job_rx.recv().await {
512 Some(j) => buffer.push_back(BufferedJob::new(j)),
513 None => break,
514 }
515 }
516 top_up_buffer(&mut buffer, &mut job_rx, buffer_size);
518 state.queue_pause.wait_if_paused().await;
522
523 let loaded = single_gpu_loaded_models(&state).await;
524 let job = pick_next_job(&mut buffer, &loaded, max_deferrals);
525 let job_id = job.id.clone();
526
527 #[cfg(feature = "metrics")]
528 crate::metrics::record_queue_depth(state.queue.pending());
529 process_job(&state, job).await;
530 state.queue.decrement();
531 state.job_registry.remove(&job_id);
535 #[cfg(feature = "metrics")]
536 crate::metrics::record_queue_depth(state.queue.pending());
537 }
538 tracing::info!("generation queue worker shutting down");
539}
540
541async fn single_gpu_loaded_models(state: &AppState) -> std::collections::HashSet<String> {
542 let mut set = std::collections::HashSet::new();
543 let cache = state.model_cache.lock().await;
544 if let Some(name) = cache.active_model() {
545 set.insert(name.to_string());
546 }
547 set
548}
549
550fn multi_gpu_loaded_models(state: &AppState) -> std::collections::HashSet<String> {
556 let mut set = std::collections::HashSet::new();
557 for worker in &state.gpu_pool.workers {
558 if let Ok(active_gen) = worker.active_generation.read() {
559 if let Some(g) = active_gen.as_ref() {
560 set.insert(g.model.clone());
561 }
562 }
563 if let Ok(cache) = worker.model_cache.lock() {
564 if let Some(name) = cache.active_model() {
565 set.insert(name.to_string());
566 }
567 }
568 }
569 set
570}
571
572pub(crate) struct BufferedJob {
576 pub(crate) job: GenerationJob,
577 pub(crate) deferred: usize,
578}
579
580impl BufferedJob {
581 fn new(job: GenerationJob) -> Self {
582 Self { job, deferred: 0 }
583 }
584}
585
586pub(crate) fn top_up_buffer(
592 buffer: &mut VecDeque<BufferedJob>,
593 job_rx: &mut tokio::sync::mpsc::Receiver<GenerationJob>,
594 buffer_size: usize,
595) {
596 while buffer.len() < buffer_size {
597 match job_rx.try_recv() {
598 Ok(j) => buffer.push_back(BufferedJob::new(j)),
599 Err(_) => break,
600 }
601 }
602}
603
604pub(crate) fn pick_next_job(
614 buffer: &mut VecDeque<BufferedJob>,
615 loaded: &std::collections::HashSet<String>,
616 max_deferrals: usize,
617) -> GenerationJob {
618 debug_assert!(
619 !buffer.is_empty(),
620 "pick_next_job requires non-empty buffer"
621 );
622
623 if let Some(head) = buffer.pop_front_if(|head| head.deferred >= max_deferrals) {
625 return head.job;
626 }
627
628 let pick_idx = buffer
630 .iter()
631 .position(|b| loaded.contains(&b.job.request.model))
632 .unwrap_or(0);
633
634 if pick_idx > 0 {
635 for (i, b) in buffer.iter_mut().enumerate() {
636 if i < pick_idx {
637 b.deferred += 1;
638 }
639 }
640 let model = buffer[pick_idx].job.request.model.clone();
641 tracing::debug!(
642 picked_model = %model,
643 head_model = %buffer.front().map(|b| b.job.request.model.as_str()).unwrap_or(""),
644 picked_index = pick_idx,
645 "queue reorder picked non-head job"
646 );
647 #[cfg(feature = "metrics")]
648 crate::metrics::record_queue_reorder();
649 }
650
651 buffer.remove(pick_idx).expect("pick_idx in range").job
652}
653
654pub(crate) const DEFAULT_LOOKAHEAD_BUFFER: usize = 8;
655pub(crate) const DEFAULT_MAX_DEFERRALS: usize = 3;
656pub(crate) const LOOKAHEAD_BUFFER_ENV: &str = "MOLD_QUEUE_LOOKAHEAD_BUFFER";
657pub(crate) const MAX_DEFERRALS_ENV: &str = "MOLD_QUEUE_MAX_DEFERRALS";
658const LOOKAHEAD_BUFFER_LOWER: usize = 1;
659const LOOKAHEAD_BUFFER_UPPER: usize = 64;
660const MAX_DEFERRALS_UPPER: usize = 32;
661
662pub(crate) fn resolve_lookahead_buffer() -> usize {
666 match std::env::var(LOOKAHEAD_BUFFER_ENV) {
667 Ok(raw) => match raw.trim().parse::<usize>() {
668 Ok(n) if (LOOKAHEAD_BUFFER_LOWER..=LOOKAHEAD_BUFFER_UPPER).contains(&n) => n,
669 Ok(n) => {
670 tracing::warn!(
671 env = LOOKAHEAD_BUFFER_ENV,
672 value = n,
673 lower = LOOKAHEAD_BUFFER_LOWER,
674 upper = LOOKAHEAD_BUFFER_UPPER,
675 "ignoring out-of-range queue lookahead buffer; using default"
676 );
677 DEFAULT_LOOKAHEAD_BUFFER
678 }
679 Err(e) => {
680 tracing::warn!(
681 env = LOOKAHEAD_BUFFER_ENV,
682 raw = %raw,
683 error = %e,
684 "ignoring unparseable queue lookahead buffer; using default"
685 );
686 DEFAULT_LOOKAHEAD_BUFFER
687 }
688 },
689 Err(_) => DEFAULT_LOOKAHEAD_BUFFER,
690 }
691}
692
693pub(crate) fn resolve_max_deferrals() -> usize {
696 match std::env::var(MAX_DEFERRALS_ENV) {
697 Ok(raw) => match raw.trim().parse::<usize>() {
698 Ok(n) if n <= MAX_DEFERRALS_UPPER => n,
699 Ok(n) => {
700 tracing::warn!(
701 env = MAX_DEFERRALS_ENV,
702 value = n,
703 upper = MAX_DEFERRALS_UPPER,
704 "ignoring out-of-range queue max-deferrals; using default"
705 );
706 DEFAULT_MAX_DEFERRALS
707 }
708 Err(e) => {
709 tracing::warn!(
710 env = MAX_DEFERRALS_ENV,
711 raw = %raw,
712 error = %e,
713 "ignoring unparseable queue max-deferrals; using default"
714 );
715 DEFAULT_MAX_DEFERRALS
716 }
717 },
718 Err(_) => DEFAULT_MAX_DEFERRALS,
719 }
720}
721
722async fn process_job(state: &AppState, job: GenerationJob) {
723 if job.result_tx.is_closed() {
725 tracing::debug!("skipping queued job — client disconnected");
726 return;
727 }
728
729 state.job_registry.mark_running(&job.id, None);
732
733 if let Some(ref tx) = job.progress_tx {
737 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued {
738 position: 0,
739 id: job.id.clone(),
740 }));
741 }
742
743 let progress_callback = job.progress_tx.as_ref().map(|tx| {
745 let tx = tx.clone();
746 Arc::new(move |event: mold_inference::ProgressEvent| {
747 let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
748 }) as model_manager::EngineProgressCallback
749 });
750
751 let activation_hint = model_manager::activation_hint_for_request(state, &job.request).await;
752 let request_has_lora = model_manager::request_has_effective_lora(&job.request);
753 if let Err(api_err) = model_manager::ensure_model_ready(
754 state,
755 &job.request.model,
756 progress_callback,
757 activation_hint,
758 request_has_lora,
759 )
760 .await
761 {
762 let err_msg = api_err.error.clone();
763 if let Some(ref tx) = job.progress_tx {
764 let _ = tx.send(SseMessage::Error(SseErrorEvent {
765 message: err_msg.clone(),
766 }));
767 }
768 let _ = job.result_tx.send(Err(err_msg));
769 return;
770 }
771
772 #[cfg(target_os = "macos")]
774 if let Some(available) = mold_inference::device::available_system_memory_bytes() {
775 if available < 1_000_000_000 {
776 tracing::warn!(
777 available_mb = available / 1_000_000,
778 "low memory before inference — system may become unstable"
779 );
780 }
781 }
782
783 let taken = {
788 let mut cache = state.model_cache.lock().await;
789 cache.take(&job.request.model)
790 };
791 let Some(mut cached_engine) = taken else {
792 let err_msg = "no engine available after model readiness check".to_string();
793 if let Some(ref tx) = job.progress_tx {
794 let _ = tx.send(SseMessage::Error(SseErrorEvent {
795 message: err_msg.clone(),
796 }));
797 }
798 let _ = job.result_tx.send(Err(err_msg));
799 return;
800 };
801
802 let active_gen = state.active_generation.clone();
803 let gen_req = job.request.clone();
804 let progress_tx = job.progress_tx.clone();
805
806 set_active_generation(state, &job.request.model, &job.request.prompt);
807
808 let was_streaming = progress_tx.is_some();
813 if let Some(ref ptx) = progress_tx {
814 let ptx = ptx.clone();
815 cached_engine.engine.set_on_progress(Box::new(move |event| {
816 let _ = ptx.send(SseMessage::Progress(progress_to_sse(event)));
817 }));
818 } else {
819 cached_engine.engine.clear_on_progress();
820 }
821
822 #[cfg(feature = "metrics")]
823 let inference_start = Instant::now();
824 let rss_before = crate::resources::ram_snapshot().used_by_mold;
828 let join_result = tokio::task::spawn_blocking(move || {
832 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
833 cached_engine.engine.generate(&gen_req)
834 }));
835 if was_streaming {
836 cached_engine.engine.clear_on_progress();
837 }
838 (cached_engine, result)
839 })
840 .await;
841
842 let rss_after = crate::resources::ram_snapshot().used_by_mold;
843 let rss_delta = rss_after as i64 - rss_before as i64;
844 tracing::info!(
845 model = %job.request.model,
846 rss_before_mb = rss_before / 1_000_000,
847 rss_after_mb = rss_after / 1_000_000,
848 rss_delta_mb = rss_delta / 1_000_000,
849 "generation memory delta"
850 );
851
852 #[cfg(feature = "metrics")]
853 let inference_duration = inference_start.elapsed().as_secs_f64();
854
855 let result = match join_result {
864 Ok((cached_engine, panic_or_result)) => {
865 {
866 let mut cache = state.model_cache.lock().await;
867 cache.restore(cached_engine);
868 }
869 clear_active_generation(state);
870 Ok(panic_or_result)
871 }
872 Err(join_err) => {
873 {
874 let mut cache = state.model_cache.lock().await;
875 cache.clear_in_flight(&job.request.model);
876 }
877 clear_active_generation(state);
878 Err(join_err)
879 }
880 };
881
882 match result {
883 Ok(Ok(Ok(mut response))) => {
884 #[cfg(feature = "metrics")]
885 crate::metrics::record_generation(&job.request.model, inference_duration);
886
887 if response.images.is_empty() && response.video.is_none() {
888 let err_msg = "generation error: engine returned no images or video".to_string();
889 if let Some(ref tx) = job.progress_tx {
890 let _ = tx.send(SseMessage::Error(SseErrorEvent {
891 message: err_msg.clone(),
892 }));
893 }
894 let _ = job.result_tx.send(Err(err_msg));
895 return;
896 }
897 let mut img = if !response.images.is_empty() {
900 response.images.remove(0)
901 } else if let Some(ref video) = response.video {
902 ImageData {
903 data: video.thumbnail.clone(),
904 format: OutputFormat::Png,
905 width: video.width,
906 height: video.height,
907 index: 0,
908 }
909 } else {
910 unreachable!("checked above");
911 };
912 if response.video.is_none() && requested_post_upscale_model(&job.request).is_some() {
913 match upscale_generated_image_on_single_worker(
914 state,
915 &job.request,
916 img,
917 job.progress_tx.as_ref(),
918 )
919 .await
920 {
921 Ok(upscaled) => {
922 img = upscaled;
923 }
924 Err(err_msg) => {
925 if let Some(ref tx) = job.progress_tx {
926 let _ = tx.send(SseMessage::Error(SseErrorEvent {
927 message: err_msg.clone(),
928 }));
929 }
930 let _ = job.result_tx.send(Err(err_msg));
931 return;
932 }
933 }
934 }
935
936 if let Some(ref dir) = job.output_dir {
940 let dir = dir.clone();
941 let model = job.request.model.clone();
942 let batch_size = job.request.batch_size;
943 let generation_time_ms = response.generation_time_ms as i64;
944 let mut metadata = OutputMetadata::from_generate_request(
945 &job.request,
946 response.seed_used,
947 None,
948 mold_core::build_info::version_string(),
949 );
950 if response.video.is_none() {
951 apply_output_dimensions_to_metadata(&mut metadata, &img);
952 }
953 let db = state.metadata_db.clone();
954 let events = state.events.clone();
955 if let Some(ref video) = response.video {
956 let video_data = video.data.clone();
957 let video_gif_preview = video.gif_preview.clone();
958 let video_format = video.format;
959 let video_metadata = metadata.clone();
960 tokio::task::spawn_blocking(move || {
961 save_video_to_dir(
962 &dir,
963 &video_data,
964 &video_gif_preview,
965 video_format,
966 &model,
967 &video_metadata,
968 Some(generation_time_ms),
969 db.as_ref().as_ref(),
970 Some(&events),
971 );
972 });
973 } else {
974 let img_clone = img.clone();
975 let metadata_clone = metadata.clone();
976 tokio::task::spawn_blocking(move || {
977 save_image_to_dir(
978 &dir,
979 &img_clone,
980 &model,
981 batch_size,
982 Some(&metadata_clone),
983 Some(generation_time_ms),
984 db.as_ref().as_ref(),
985 Some(&events),
986 );
987 });
988 }
989 }
990
991 if let Some(ref tx) = job.progress_tx {
993 let event = build_sse_complete_event(&response, &img);
994 let _ = tx.send(SseMessage::Complete(event));
995 }
996
997 let _ = job.result_tx.send(Ok(GenerationJobResult {
999 image: img,
1000 response,
1001 }));
1002 }
1003 Ok(Ok(Err(e))) => {
1004 #[cfg(feature = "metrics")]
1005 crate::metrics::record_generation_error(&job.request.model);
1006
1007 *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
1008 tracing::error!("generation error: {e:#}");
1009 let err_msg = format!("generation error: {}", clean_error_message(&e));
1010 if let Some(ref tx) = job.progress_tx {
1011 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1012 message: err_msg.clone(),
1013 }));
1014 }
1015 let _ = job.result_tx.send(Err(err_msg));
1016 }
1017 Ok(Err(panic_payload)) => {
1018 #[cfg(feature = "metrics")]
1019 crate::metrics::record_generation_error(&job.request.model);
1020
1021 *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
1022 let msg = panic_payload
1023 .downcast_ref::<String>()
1024 .map(|s| s.as_str())
1025 .or_else(|| panic_payload.downcast_ref::<&str>().copied())
1026 .unwrap_or("unknown panic");
1027 tracing::error!("inference panicked: {msg}");
1028 let err_msg = format!("inference panicked: {msg}");
1029 if let Some(ref tx) = job.progress_tx {
1030 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1031 message: err_msg.clone(),
1032 }));
1033 }
1034 let _ = job.result_tx.send(Err(err_msg));
1035 }
1036 Err(join_err) => {
1037 #[cfg(feature = "metrics")]
1038 crate::metrics::record_generation_error(&job.request.model);
1039
1040 *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
1041 tracing::error!("inference task join error: {join_err:?}");
1042 let err_msg = "inference task failed".to_string();
1043 if let Some(ref tx) = job.progress_tx {
1044 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1045 message: err_msg.clone(),
1046 }));
1047 }
1048 let _ = job.result_tx.send(Err(err_msg));
1049 }
1050 }
1051}
1052
1053pub async fn run_queue_dispatcher(
1063 job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
1064 state: AppState,
1065) {
1066 tracing::debug!("multi-GPU queue dispatcher started");
1067 let buffer_size = resolve_lookahead_buffer();
1068 let max_deferrals = resolve_max_deferrals();
1069 run_queue_dispatcher_with_tuning(job_rx, state, buffer_size, max_deferrals).await;
1070}
1071
1072async fn run_queue_dispatcher_with_tuning(
1073 mut job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
1074 state: AppState,
1075 buffer_size: usize,
1076 max_deferrals: usize,
1077) {
1078 let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(buffer_size);
1079
1080 loop {
1081 state.queue_pause.wait_if_paused().await;
1083 if buffer.is_empty() {
1084 match job_rx.recv().await {
1085 Some(j) => buffer.push_back(BufferedJob::new(j)),
1086 None => break,
1087 }
1088 }
1089 top_up_buffer(&mut buffer, &mut job_rx, buffer_size);
1090 state.queue_pause.wait_if_paused().await;
1094
1095 let loaded = multi_gpu_loaded_models(&state);
1096 let job = pick_next_job(&mut buffer, &loaded, max_deferrals);
1097
1098 #[cfg(feature = "metrics")]
1099 crate::metrics::record_queue_depth(state.queue.pending());
1100
1101 let job_id = job.id.clone();
1102 let model_name = job.request.model.clone();
1103 let estimated_vram = estimate_model_vram(&model_name);
1104
1105 if let Some(err_msg) = crate::gpu_pool::model_unschedulable_message(&model_name) {
1106 tracing::warn!(model = %model_name, "{err_msg}");
1107 if let Some(tx) = job.progress_tx {
1108 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1109 message: err_msg.clone(),
1110 }));
1111 }
1112 let _ = job.result_tx.send(Err(err_msg));
1113 state.queue.decrement();
1114 state.job_registry.remove(&job_id);
1115 #[cfg(feature = "metrics")]
1116 crate::metrics::record_queue_depth(state.queue.pending());
1117 continue;
1118 }
1119
1120 let placement_gpu = match state
1121 .gpu_pool
1122 .resolve_explicit_placement_gpu(job.request.placement.as_ref())
1123 {
1124 Ok(ordinal) => ordinal,
1125 Err(err_msg) => {
1126 tracing::warn!(model = %model_name, "{err_msg}");
1127 if let Some(tx) = job.progress_tx {
1128 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1129 message: err_msg.clone(),
1130 }));
1131 }
1132 let _ = job.result_tx.send(Err(err_msg));
1133 state.queue.decrement();
1134 state.job_registry.remove(&job_id);
1135 #[cfg(feature = "metrics")]
1136 crate::metrics::record_queue_depth(state.queue.pending());
1137 continue;
1138 }
1139 };
1140 let preferred_gpu = state
1141 .job_registry
1142 .target_gpu(&job_id)
1143 .flatten()
1144 .or(placement_gpu);
1145
1146 if job.result_tx.is_closed() {
1147 tracing::debug!(model = %model_name, "skipping queued multi-GPU job — client disconnected");
1148 state.queue.decrement();
1149 state.job_registry.remove(&job_id);
1150 #[cfg(feature = "metrics")]
1151 crate::metrics::record_queue_depth(state.queue.pending());
1152 continue;
1153 }
1154
1155 let mut gpu_job = Some(GpuJob {
1157 id: job.id.clone(),
1158 model: model_name.clone(),
1159 request: job.request,
1160 progress_tx: job.progress_tx,
1161 result_tx: job.result_tx,
1162 output_dir: job.output_dir,
1163 config: state.config.clone(),
1164 metadata_db: state.metadata_db.clone(),
1165 queue: state.queue.clone(),
1166 registry: state.job_registry.clone(),
1167 events: state.events.clone(),
1168 });
1169
1170 let mut skip: Vec<usize> = if preferred_gpu.is_none() {
1171 let failed = crate::gpu_pool::failed_ordinals_for_model(&model_name);
1172 if failed.len() < state.gpu_pool.worker_count() {
1173 failed
1174 } else {
1175 Vec::new()
1176 }
1177 } else {
1178 Vec::new()
1179 };
1180 let mut dispatched = false;
1181
1182 while !dispatched {
1183 if gpu_job
1184 .as_ref()
1185 .is_some_and(|pending| pending.result_tx.is_closed())
1186 {
1187 tracing::debug!(
1188 model = %model_name,
1189 "dropping queued multi-GPU job before dispatch — client disconnected"
1190 );
1191 state.queue.decrement();
1192 state.job_registry.remove(&job_id);
1193 break;
1194 }
1195
1196 let worker = if let Some(ordinal) = preferred_gpu {
1197 state.gpu_pool.worker_by_ordinal(ordinal)
1198 } else {
1199 state
1200 .gpu_pool
1201 .select_worker_excluding(&model_name, estimated_vram, &skip)
1202 };
1203
1204 let Some(worker) = worker else {
1205 if preferred_gpu.is_none() && state.gpu_pool.worker_count() > 0 {
1206 tracing::warn!(
1207 model = %model_name,
1208 "all GPU workers are temporarily unavailable; keeping job queued"
1209 );
1210 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1211 continue;
1212 }
1213 let rejected = gpu_job
1214 .take()
1215 .expect("gpu_job retained after failed dispatch");
1216 let err_msg = if state.gpu_pool.worker_count() == 0 {
1217 format!("no GPU available for model {model_name}")
1218 } else if let Some(ordinal) = preferred_gpu {
1219 format!("gpu:{ordinal} is not available for model {model_name}")
1220 } else {
1221 format!("no GPU worker available for model {model_name}")
1222 };
1223 tracing::error!(model = %model_name, "{err_msg}");
1224 if let Some(tx) = rejected.progress_tx {
1225 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1226 message: err_msg.clone(),
1227 }));
1228 }
1229 let _ = rejected.result_tx.send(Err(err_msg));
1230 state.queue.decrement();
1231 state.job_registry.remove(&job_id);
1232 break;
1233 };
1234
1235 worker.in_flight.fetch_add(1, Ordering::SeqCst);
1237 let pending = gpu_job.take().expect("gpu_job present in retry loop");
1238 if preferred_gpu.is_none() {
1239 let _ = state
1240 .job_registry
1241 .set_target_gpu(&job_id, Some(worker.gpu.ordinal));
1242 }
1243 match worker.job_tx.try_send(pending) {
1244 Ok(()) => {
1245 dispatched = true;
1246 }
1247 Err(std::sync::mpsc::TrySendError::Full(j)) => {
1248 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1249 if preferred_gpu.is_none() {
1250 let _ = state.job_registry.set_target_gpu(&job_id, None);
1251 }
1252 gpu_job = Some(j);
1253 if preferred_gpu.is_none() {
1254 skip.push(worker.gpu.ordinal);
1255 if skip.len() >= state.gpu_pool.worker_count().max(1) {
1256 skip.clear();
1257 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1258 }
1259 } else {
1260 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1261 }
1262 }
1263 Err(std::sync::mpsc::TrySendError::Disconnected(j)) => {
1264 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1265 if preferred_gpu.is_none() {
1266 let _ = state.job_registry.set_target_gpu(&job_id, None);
1267 }
1268 tracing::warn!(
1269 gpu = worker.gpu.ordinal,
1270 "GPU worker disconnected — retrying dispatch"
1271 );
1272 gpu_job = Some(j);
1273 if preferred_gpu.is_none() {
1274 skip.push(worker.gpu.ordinal);
1275 } else {
1276 let rejected = gpu_job.take().expect("gpu_job retained after disconnect");
1277 let err_msg = format!(
1278 "gpu:{} disconnected while dispatching model {model_name}",
1279 worker.gpu.ordinal
1280 );
1281 if let Some(tx) = rejected.progress_tx {
1282 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1283 message: err_msg.clone(),
1284 }));
1285 }
1286 let _ = rejected.result_tx.send(Err(err_msg));
1287 state.queue.decrement();
1288 state.job_registry.remove(&job_id);
1289 break;
1290 }
1291 }
1292 }
1293 }
1294 #[cfg(feature = "metrics")]
1295 crate::metrics::record_queue_depth(state.queue.pending());
1296 }
1297 tracing::info!("multi-GPU queue dispatcher shutting down");
1298}
1299
1300pub fn estimate_model_vram(model_name: &str) -> u64 {
1302 let lower = model_name.to_lowercase();
1305 if lower.contains("flux2")
1306 && lower.contains("9b")
1307 && (lower.contains(":bf16") || lower.contains(":fp16"))
1308 {
1309 32_000_000_000 } else if lower.contains(":q4") {
1311 6_000_000_000 } else if lower.contains(":q8") || lower.contains(":fp8") {
1313 12_000_000_000 } else if lower.contains(":bf16") || lower.contains(":fp16") {
1315 24_000_000_000 } else if lower.contains("sd15") || lower.contains("sd1.5") {
1317 4_000_000_000 } else {
1319 8_000_000_000
1321 }
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 use super::*;
1327 use crate::gpu_pool::{GpuPool, GpuWorker};
1328 use crate::model_cache::ModelCache;
1329 use crate::state::QueueHandle;
1330 use mold_core::{GenerateRequest, ImageData, ModelConfig, OutputFormat};
1331 use mold_db::MetadataDb;
1332 use mold_inference::device::DiscoveredGpu;
1333 use mold_inference::shared_pool::SharedPool;
1334 use std::sync::atomic::AtomicUsize;
1335 use std::sync::{Arc, Mutex, RwLock};
1336 use tempfile::TempDir;
1337
1338 fn fake_request(model: &str) -> GenerateRequest {
1341 GenerateRequest {
1342 prompt: "a cat".to_string(),
1343 negative_prompt: None,
1344 model: model.to_string(),
1345 width: 512,
1346 height: 512,
1347 steps: 4,
1348 guidance: 3.5,
1349 seed: Some(7),
1350 batch_size: 1,
1351 output_format: Some(OutputFormat::Png),
1352 embed_metadata: None,
1353 scheduler: None,
1354 cfg_plus: None,
1355 source_image: None,
1356 edit_images: None,
1357 strength: 0.75,
1358 mask_image: None,
1359 control_image: None,
1360 control_model: None,
1361 control_scale: 1.0,
1362 expand: None,
1363 original_prompt: None,
1364 lora: None,
1365 frames: None,
1366 fps: None,
1367 upscale_model: None,
1368 gif_preview: false,
1369 enable_audio: None,
1370 audio_file: None,
1371 audio_file_path: None,
1372 source_video: None,
1373 source_video_path: None,
1374 keyframes: None,
1375 pipeline: None,
1376 loras: None,
1377 retake_range: None,
1378 spatial_upscale: None,
1379 temporal_upscale: None,
1380 placement: None,
1381 }
1382 }
1383
1384 fn fake_image() -> ImageData {
1385 ImageData {
1386 data: vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
1389 format: OutputFormat::Png,
1390 width: 512,
1391 height: 512,
1392 index: 0,
1393 }
1394 }
1395
1396 fn test_worker(
1397 ordinal: usize,
1398 channel_size: usize,
1399 ) -> (
1400 Arc<GpuWorker>,
1401 std::sync::mpsc::Receiver<crate::gpu_pool::GpuJob>,
1402 ) {
1403 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(channel_size);
1404 let worker = Arc::new(GpuWorker {
1405 gpu: DiscoveredGpu {
1406 ordinal,
1407 name: format!("gpu{ordinal}"),
1408 total_vram_bytes: 24_000_000_000,
1409 free_vram_bytes: 24_000_000_000,
1410 },
1411 model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
1412 active_generation: Arc::new(RwLock::new(None)),
1413 model_load_lock: Arc::new(Mutex::new(())),
1414 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
1415 in_flight: AtomicUsize::new(0),
1416 consecutive_failures: AtomicUsize::new(0),
1417 degraded_until: RwLock::new(None),
1418 job_tx,
1419 });
1420 (worker, job_rx)
1421 }
1422
1423 fn empty_test_state(config: mold_core::Config) -> crate::state::AppState {
1424 crate::state::AppState::empty(
1425 config,
1426 QueueHandle::new(tokio::sync::mpsc::channel(1).0),
1427 crate::state::AppState::empty_gpu_pool(),
1428 200,
1429 )
1430 }
1431
1432 #[test]
1433 fn save_image_to_dir_writes_file_and_creates_missing_dir() {
1434 let tmp = TempDir::new().unwrap();
1435 let nested = tmp.path().join("sub/output");
1436 assert!(!nested.exists());
1437
1438 save_image_to_dir(
1439 &nested,
1440 &fake_image(),
1441 "flux-dev:q4",
1442 1,
1443 None,
1444 None,
1445 None,
1446 None,
1447 );
1448
1449 assert!(nested.exists(), "save should mkdir -p");
1450 let entries: Vec<_> = std::fs::read_dir(&nested).unwrap().collect();
1451 assert_eq!(entries.len(), 1);
1452 let name = entries[0].as_ref().unwrap().file_name();
1453 let name_str = name.to_string_lossy();
1454 assert!(name_str.starts_with("mold-flux-dev-q4-"), "{name_str}");
1456 assert!(name_str.ends_with(".png"), "{name_str}");
1457 }
1458
1459 #[test]
1460 fn save_image_to_dir_includes_batch_index_when_batch_size_gt_1() {
1461 let tmp = TempDir::new().unwrap();
1462 let mut img = fake_image();
1463 img.index = 3;
1464 img.format = OutputFormat::Jpeg;
1465 img.data = vec![0xFF, 0xD8, 0xFF, 0xE0]; save_image_to_dir(tmp.path(), &img, "sdxl", 4, None, None, None, None);
1468
1469 let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1470 let name = entries[0]
1471 .as_ref()
1472 .unwrap()
1473 .file_name()
1474 .to_string_lossy()
1475 .to_string();
1476 assert!(
1477 name.contains("-3.jpeg"),
1478 "expected batch index suffix: {name}"
1479 );
1480 }
1481
1482 #[test]
1483 fn save_image_to_dir_upserts_metadata_row_when_db_provided() {
1484 let tmp = TempDir::new().unwrap();
1485 let db = MetadataDb::open_in_memory().unwrap();
1486 let req = fake_request("flux-dev:q4");
1487 let meta = OutputMetadata::from_generate_request(&req, 42, None, "test-version");
1488
1489 save_image_to_dir(
1490 tmp.path(),
1491 &fake_image(),
1492 "flux-dev:q4",
1493 1,
1494 Some(&meta),
1495 Some(1234),
1496 Some(&db),
1497 None,
1498 );
1499
1500 let rows = db.list(Some(tmp.path())).unwrap();
1501 assert_eq!(rows.len(), 1, "exactly one DB row for the saved file");
1502 let rec = &rows[0];
1503 assert_eq!(rec.metadata.prompt, "a cat");
1504 assert_eq!(rec.metadata.seed, 42);
1505 assert_eq!(rec.metadata.version, "test-version");
1506 assert_eq!(rec.format, OutputFormat::Png);
1507 assert_eq!(rec.generation_time_ms, Some(1234));
1508 assert!(rec.file_size_bytes.unwrap_or(0) > 0);
1510 }
1511
1512 #[test]
1513 fn save_image_to_dir_skips_db_when_metadata_is_none() {
1514 let tmp = TempDir::new().unwrap();
1515 let db = MetadataDb::open_in_memory().unwrap();
1516
1517 save_image_to_dir(
1518 tmp.path(),
1519 &fake_image(),
1520 "flux-dev:q4",
1521 1,
1522 None, Some(1234),
1524 Some(&db),
1525 None,
1526 );
1527
1528 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1);
1531 assert_eq!(db.list(None).unwrap().len(), 0);
1532 }
1533
1534 #[test]
1535 fn save_image_to_dir_invalid_path_does_not_panic() {
1536 save_image_to_dir(
1539 std::path::Path::new("/dev/null/cant-mkdir-here"),
1540 &fake_image(),
1541 "test",
1542 1,
1543 None,
1544 None,
1545 None,
1546 None,
1547 );
1548 }
1549
1550 #[test]
1551 fn save_image_to_dir_emits_gallery_added_with_row_when_db_records() {
1552 let tmp = TempDir::new().unwrap();
1553 let db = MetadataDb::open_in_memory().unwrap();
1554 let req = fake_request("flux-dev:q4");
1555 let meta = OutputMetadata::from_generate_request(&req, 42, None, "test-version");
1556 let events = crate::events::EventBroadcaster::new();
1557 let mut rx = events.subscribe();
1558
1559 save_image_to_dir(
1560 tmp.path(),
1561 &fake_image(),
1562 "flux-dev:q4",
1563 1,
1564 Some(&meta),
1565 Some(1234),
1566 Some(&db),
1567 Some(&events),
1568 );
1569
1570 match rx.try_recv().unwrap() {
1571 mold_core::ServerEvent::GalleryAdded { filename, image } => {
1572 assert!(filename.ends_with(".png"), "{filename}");
1573 let img = image.expect("DB recorded — event must carry the gallery row");
1574 assert_eq!(img.filename, filename);
1575 assert_eq!(img.metadata.prompt, "a cat");
1576 }
1577 other => panic!("expected gallery_added, got {other:?}"),
1578 }
1579 }
1580
1581 #[test]
1582 fn save_image_to_dir_emits_gallery_added_without_row_when_db_absent() {
1583 let tmp = TempDir::new().unwrap();
1584 let events = crate::events::EventBroadcaster::new();
1585 let mut rx = events.subscribe();
1586
1587 save_image_to_dir(
1588 tmp.path(),
1589 &fake_image(),
1590 "flux-dev:q4",
1591 1,
1592 None,
1593 None,
1594 None, Some(&events),
1596 );
1597
1598 match rx.try_recv().unwrap() {
1599 mold_core::ServerEvent::GalleryAdded { image, .. } => {
1600 assert!(image.is_none(), "no DB → clients must refetch");
1601 }
1602 other => panic!("expected gallery_added, got {other:?}"),
1603 }
1604 }
1605
1606 #[test]
1607 fn save_image_to_dir_emits_nothing_on_write_failure() {
1608 let events = crate::events::EventBroadcaster::new();
1609 let mut rx = events.subscribe();
1610
1611 save_image_to_dir(
1612 std::path::Path::new("/dev/null/cant-mkdir-here"),
1613 &fake_image(),
1614 "test",
1615 1,
1616 None,
1617 None,
1618 None,
1619 Some(&events),
1620 );
1621
1622 assert!(
1623 rx.try_recv().is_err(),
1624 "failed save must not announce a gallery entry"
1625 );
1626 }
1627
1628 #[test]
1629 fn save_video_to_dir_emits_gallery_added() {
1630 let tmp = TempDir::new().unwrap();
1631 let db = MetadataDb::open_in_memory().unwrap();
1632 let req = fake_request("ltx-video:fp16");
1633 let meta = OutputMetadata::from_generate_request(&req, 1, None, "v");
1634 let events = crate::events::EventBroadcaster::new();
1635 let mut rx = events.subscribe();
1636
1637 save_video_to_dir(
1638 tmp.path(),
1639 b"fake mp4 bytes",
1640 b"",
1641 OutputFormat::Mp4,
1642 "ltx-video:fp16",
1643 &meta,
1644 Some(5000),
1645 Some(&db),
1646 Some(&events),
1647 );
1648
1649 match rx.try_recv().unwrap() {
1650 mold_core::ServerEvent::GalleryAdded { filename, image } => {
1651 assert!(filename.ends_with(".mp4"), "{filename}");
1652 assert!(image.is_some());
1653 }
1654 other => panic!("expected gallery_added, got {other:?}"),
1655 }
1656 }
1657
1658 #[test]
1659 fn save_video_to_dir_writes_mp4_and_records_metadata() {
1660 let tmp = TempDir::new().unwrap();
1661 let db = MetadataDb::open_in_memory().unwrap();
1662 let mut req = fake_request("ltx-video:fp16");
1663 req.frames = Some(25);
1664 req.fps = Some(24);
1665 let meta = OutputMetadata::from_generate_request(&req, 99, None, "test-version");
1666
1667 let bytes = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom".to_vec();
1670
1671 save_video_to_dir(
1672 tmp.path(),
1673 &bytes,
1674 b"",
1675 OutputFormat::Mp4,
1676 "ltx-video:fp16",
1677 &meta,
1678 Some(5000),
1679 Some(&db),
1680 None,
1681 );
1682
1683 let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1684 assert_eq!(entries.len(), 1);
1685 let name = entries[0]
1686 .as_ref()
1687 .unwrap()
1688 .file_name()
1689 .to_string_lossy()
1690 .to_string();
1691 assert!(name.starts_with("mold-ltx-video-fp16-"), "{name}");
1692 assert!(name.ends_with(".mp4"), "{name}");
1693
1694 let rows = db.list(Some(tmp.path())).unwrap();
1695 assert_eq!(rows.len(), 1);
1696 assert_eq!(rows[0].format, OutputFormat::Mp4);
1697 assert_eq!(rows[0].metadata.frames, Some(25));
1698 assert_eq!(rows[0].metadata.fps, Some(24));
1699 assert_eq!(rows[0].generation_time_ms, Some(5000));
1700 }
1701
1702 #[test]
1703 fn save_video_to_dir_without_db_still_writes_file() {
1704 let tmp = TempDir::new().unwrap();
1705 let req = fake_request("ltx-video:fp16");
1706 let meta = OutputMetadata::from_generate_request(&req, 1, None, "v");
1707
1708 save_video_to_dir(
1709 tmp.path(),
1710 b"fake gif bytes",
1711 b"",
1712 OutputFormat::Gif,
1713 "ltx-video:fp16",
1714 &meta,
1715 None,
1716 None,
1717 None,
1718 );
1719
1720 let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1721 assert_eq!(entries.len(), 1);
1722 let name = entries[0]
1723 .as_ref()
1724 .unwrap()
1725 .file_name()
1726 .to_string_lossy()
1727 .to_string();
1728 assert!(name.ends_with(".gif"), "{name}");
1729 }
1730
1731 #[test]
1732 fn save_video_to_dir_invalid_path_does_not_panic() {
1733 let req = fake_request("ltx-video:fp16");
1734 let meta = OutputMetadata::from_generate_request(&req, 1, None, "v");
1735 save_video_to_dir(
1736 std::path::Path::new("/dev/null/nope"),
1737 b"x",
1738 b"",
1739 OutputFormat::Mp4,
1740 "test",
1741 &meta,
1742 None,
1743 None,
1744 None,
1745 );
1746 }
1747
1748 #[test]
1755 fn save_video_preview_gif_writes_to_preview_cache() {
1756 let td = tempfile::tempdir().unwrap();
1757 let preview_dir = td.path().join("cache").join("previews");
1758
1759 const GIF: &[u8] = b"GIF89a\x01\x00\x01\x00\x00\x00\x00\x3b";
1760 save_video_preview_gif_to(&preview_dir, "ltx2-42.mp4", GIF);
1761
1762 let expected = preview_dir.join("ltx2-42.mp4.preview.gif");
1763 assert!(
1764 expected.is_file(),
1765 "preview gif should land at {}",
1766 expected.display()
1767 );
1768 assert_eq!(std::fs::read(&expected).unwrap(), GIF);
1769 }
1770
1771 #[test]
1772 fn build_sse_complete_event_video_carries_mp4_payload_and_metadata() {
1773 let video = mold_core::VideoData {
1780 data: vec![0x00, 0x00, 0x00, 0x18, b'f', b't', b'y', b'p'],
1781 format: OutputFormat::Mp4,
1782 width: 768,
1783 height: 512,
1784 frames: 25,
1785 fps: 24,
1786 thumbnail: vec![0x89, 0x50, 0x4E, 0x47],
1787 gif_preview: vec![b'G', b'I', b'F', b'8'],
1788 has_audio: true,
1789 duration_ms: Some(1040),
1790 audio_sample_rate: Some(44100),
1791 audio_channels: Some(2),
1792 };
1793 let resp = mold_core::GenerateResponse {
1794 images: vec![],
1795 video: Some(video.clone()),
1796 generation_time_ms: 1234,
1797 model: "ltx-2-19b-distilled:fp8".to_string(),
1798 seed_used: 7,
1799 gpu: Some(0),
1800 };
1801 let thumb_img = ImageData {
1804 data: video.thumbnail.clone(),
1805 format: OutputFormat::Png,
1806 width: video.width,
1807 height: video.height,
1808 index: 0,
1809 };
1810
1811 let event = build_sse_complete_event(&resp, &thumb_img);
1812
1813 let b64 = base64::engine::general_purpose::STANDARD;
1814 assert_eq!(event.image, b64.encode(&video.data));
1815 assert_eq!(event.format, OutputFormat::Mp4);
1816 assert_eq!(event.video_frames, Some(25));
1817 assert_eq!(event.video_fps, Some(24));
1818 assert_eq!(event.video_thumbnail, Some(b64.encode(&video.thumbnail)));
1819 assert_eq!(
1820 event.video_gif_preview,
1821 Some(b64.encode(&video.gif_preview))
1822 );
1823 assert!(event.video_has_audio);
1824 assert_eq!(event.video_duration_ms, Some(1040));
1825 assert_eq!(event.gpu, Some(0));
1826 }
1827
1828 #[test]
1829 fn build_sse_complete_event_video_empty_gif_preview_omits_field() {
1830 let video = mold_core::VideoData {
1831 data: vec![0x00, 0x00, 0x00, 0x18],
1832 format: OutputFormat::Mp4,
1833 width: 256,
1834 height: 256,
1835 frames: 17,
1836 fps: 12,
1837 thumbnail: vec![0x89, 0x50],
1838 gif_preview: Vec::new(),
1839 has_audio: false,
1840 duration_ms: None,
1841 audio_sample_rate: None,
1842 audio_channels: None,
1843 };
1844 let resp = mold_core::GenerateResponse {
1845 images: vec![],
1846 video: Some(video),
1847 generation_time_ms: 0,
1848 model: "m".to_string(),
1849 seed_used: 0,
1850 gpu: None,
1851 };
1852 let event = build_sse_complete_event(&resp, &fake_image());
1853 assert!(event.video_gif_preview.is_none());
1854 assert!(!event.video_has_audio);
1855 }
1856
1857 #[test]
1858 fn build_sse_complete_event_image_clears_all_video_fields() {
1859 let resp = mold_core::GenerateResponse {
1860 images: vec![fake_image()],
1861 video: None,
1862 generation_time_ms: 100,
1863 model: "flux-schnell:q8".to_string(),
1864 seed_used: 5,
1865 gpu: None,
1866 };
1867 let event = build_sse_complete_event(&resp, &fake_image());
1868 assert_eq!(event.format, OutputFormat::Png);
1869 assert!(event.video_frames.is_none());
1870 assert!(event.video_fps.is_none());
1871 assert!(event.video_thumbnail.is_none());
1872 assert!(event.video_gif_preview.is_none());
1873 assert!(!event.video_has_audio);
1874 assert!(event.video_duration_ms.is_none());
1875 }
1876
1877 #[test]
1878 fn post_generation_upscale_replaces_image_response_dimensions() {
1879 let mut req = fake_request("flux-dev:q4");
1880 req.upscale_model = Some("real-esrgan-x4plus:fp16".to_string());
1881 let mut response = mold_core::GenerateResponse {
1882 images: vec![],
1883 video: None,
1884 generation_time_ms: 100,
1885 model: "flux-dev:q4".to_string(),
1886 seed_used: 5,
1887 gpu: None,
1888 };
1889 let img = fake_image();
1890 let upscaled = mold_core::UpscaleResponse {
1891 image: ImageData {
1892 data: vec![1, 2, 3],
1893 format: OutputFormat::Png,
1894 width: 2048,
1895 height: 2048,
1896 index: 0,
1897 },
1898 upscale_time_ms: 42,
1899 model: "real-esrgan-x4plus:fp16".to_string(),
1900 scale_factor: 4,
1901 original_width: 512,
1902 original_height: 512,
1903 };
1904
1905 let next = apply_upscale_response_to_image_generation(&req, &mut response, img, upscaled)
1906 .expect("image upscale should apply");
1907 let event = build_sse_complete_event(&response, &next);
1908 let mut metadata =
1909 OutputMetadata::from_generate_request(&req, response.seed_used, None, "test-version");
1910 apply_output_dimensions_to_metadata(&mut metadata, &next);
1911
1912 assert_eq!(next.width, 2048);
1913 assert_eq!(next.height, 2048);
1914 assert_eq!(event.width, 2048);
1915 assert_eq!(event.height, 2048);
1916 assert_eq!(metadata.width, 2048);
1917 assert_eq!(metadata.height, 2048);
1918 assert_eq!(
1919 metadata.upscale_model.as_deref(),
1920 Some("real-esrgan-x4plus:fp16")
1921 );
1922 }
1923
1924 #[test]
1925 fn post_generation_upscale_skips_video_responses() {
1926 let mut req = fake_request("ltx-video:fp16");
1927 req.upscale_model = Some("real-esrgan-x4plus:fp16".to_string());
1928 let video = mold_core::VideoData {
1929 data: vec![0, 0, 0, 24],
1930 format: OutputFormat::Mp4,
1931 width: 512,
1932 height: 512,
1933 frames: 25,
1934 fps: 24,
1935 thumbnail: vec![9, 9],
1936 gif_preview: vec![],
1937 has_audio: false,
1938 duration_ms: None,
1939 audio_sample_rate: None,
1940 audio_channels: None,
1941 };
1942 let mut response = mold_core::GenerateResponse {
1943 images: vec![],
1944 video: Some(video),
1945 generation_time_ms: 100,
1946 model: "ltx-video:fp16".to_string(),
1947 seed_used: 5,
1948 gpu: None,
1949 };
1950 let img = fake_image();
1951 let upscaled = mold_core::UpscaleResponse {
1952 image: ImageData {
1953 data: vec![1, 2, 3],
1954 format: OutputFormat::Png,
1955 width: 2048,
1956 height: 2048,
1957 index: 0,
1958 },
1959 upscale_time_ms: 42,
1960 model: "real-esrgan-x4plus:fp16".to_string(),
1961 scale_factor: 4,
1962 original_width: 512,
1963 original_height: 512,
1964 };
1965
1966 let next = apply_upscale_response_to_image_generation(&req, &mut response, img, upscaled)
1967 .expect("video upscale should be skipped");
1968
1969 assert_eq!(next.width, 512);
1970 assert_eq!(next.height, 512);
1971 assert!(response.video.is_some());
1972 }
1973
1974 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1975 async fn single_worker_post_upscale_noops_without_model() {
1976 let state = empty_test_state(mold_core::Config::default());
1977 let req = fake_request("flux-dev:q4");
1978
1979 let next = upscale_generated_image_on_single_worker(&state, &req, fake_image(), None)
1980 .await
1981 .expect("missing upscale model should leave the image unchanged");
1982
1983 assert_eq!(next.width, 512);
1984 assert_eq!(next.height, 512);
1985 assert_eq!(next.index, 0);
1986 }
1987
1988 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1989 async fn single_worker_post_upscale_rejects_unknown_upscaler_manifest() {
1990 let state = empty_test_state(mold_core::Config::default());
1991 let mut req = fake_request("flux-dev:q4");
1992 req.upscale_model = Some("definitely-not-a-real-upscaler:fp16".to_string());
1993 let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
1994
1995 let err = upscale_generated_image_on_single_worker(
1996 &state,
1997 &req,
1998 fake_image(),
1999 Some(&progress_tx),
2000 )
2001 .await
2002 .expect_err("unknown upscalers should fail before generation completes");
2003
2004 assert!(err.contains("unknown upscaler model"), "got: {err}");
2005 let first_progress = progress_rx
2006 .try_recv()
2007 .expect("loading stage should be emitted before validation fails");
2008 assert!(matches!(
2009 first_progress,
2010 SseMessage::Progress(SseProgressEvent::StageStart { .. })
2011 ));
2012 }
2013
2014 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2015 async fn single_worker_post_upscale_surfaces_missing_weights_path() {
2016 let tmp = TempDir::new().unwrap();
2017 let missing_weights = tmp.path().join("missing-upscaler.safetensors");
2018 let mut config = mold_core::Config::default();
2019 config.models.insert(
2020 "real-esrgan-x4plus:fp16".to_string(),
2021 ModelConfig {
2022 transformer: Some(missing_weights.display().to_string()),
2023 ..Default::default()
2024 },
2025 );
2026 let state = empty_test_state(config);
2027 let mut req = fake_request("flux-dev:q4");
2028 req.upscale_model = Some("real-esrgan-x4plus:fp16".to_string());
2029 let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
2030
2031 let err = upscale_generated_image_on_single_worker(
2032 &state,
2033 &req,
2034 fake_image(),
2035 Some(&progress_tx),
2036 )
2037 .await
2038 .expect_err("missing weight files should be surfaced");
2039
2040 assert!(err.contains("upscale failed"), "got: {err}");
2041 assert!(err.contains("upscaler weights not found"), "got: {err}");
2042 let first_progress = progress_rx
2043 .try_recv()
2044 .expect("loading stage should be emitted before loading fails");
2045 assert!(matches!(
2046 first_progress,
2047 SseMessage::Progress(SseProgressEvent::StageStart { .. })
2048 ));
2049 }
2050
2051 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2052 async fn queue_dispatcher_waits_for_worker_capacity_instead_of_rejecting() {
2053 let (worker, worker_rx) = test_worker(0, 1);
2054 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2055 let queue = QueueHandle::new(job_tx.clone());
2056 let state = crate::state::AppState::empty(
2057 mold_core::Config::default(),
2058 queue.clone(),
2059 Arc::new(GpuPool {
2060 workers: vec![worker.clone()],
2061 }),
2062 8,
2063 );
2064
2065 let (filler_result_tx, _filler_result_rx) = tokio::sync::oneshot::channel();
2066 let filler_job = crate::gpu_pool::GpuJob {
2067 id: String::new(),
2068 model: "busy-model".to_string(),
2069 request: fake_request("busy-model"),
2070 progress_tx: None,
2071 result_tx: filler_result_tx,
2072 output_dir: None,
2073 config: state.config.clone(),
2074 metadata_db: state.metadata_db.clone(),
2075 queue: state.queue.clone(),
2076 registry: state.job_registry.clone(),
2077 events: state.events.clone(),
2078 };
2079 worker.job_tx.send(filler_job).unwrap();
2080
2081 let dispatcher = tokio::spawn(run_queue_dispatcher_with_tuning(
2082 job_rx,
2083 state.clone(),
2084 8,
2085 DEFAULT_MAX_DEFERRALS,
2086 ));
2087
2088 let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
2089 let job = crate::state::GenerationJob {
2090 id: String::new(),
2091 request: fake_request("flux-dev:q4"),
2092 progress_tx: None,
2093 result_tx,
2094 output_dir: None,
2095 };
2096 let _position = queue.submit(job, 8).await.unwrap();
2097
2098 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2099 assert!(
2100 result_rx.try_recv().is_err(),
2101 "dispatcher should keep the job pending while all worker channels are full"
2102 );
2103
2104 let _filler = worker_rx
2105 .recv()
2106 .expect("filler job should occupy the local channel");
2107 let dispatched = worker_rx
2108 .recv_timeout(std::time::Duration::from_secs(1))
2109 .expect("queued job should dispatch once capacity is available");
2110 assert_eq!(dispatched.model, "flux-dev:q4");
2111
2112 drop(job_tx);
2113 dispatcher.abort();
2114 }
2115
2116 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2117 async fn queue_dispatcher_waits_for_degraded_worker_recovery_instead_of_rejecting() {
2118 let (worker, worker_rx) = test_worker(0, 1);
2119 worker.consecutive_failures.store(3, Ordering::SeqCst);
2120 *worker.degraded_until.write().unwrap() =
2121 Some(Instant::now() + std::time::Duration::from_secs(60));
2122
2123 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2124 let queue = QueueHandle::new(job_tx.clone());
2125 let state = crate::state::AppState::empty(
2126 mold_core::Config::default(),
2127 queue.clone(),
2128 Arc::new(GpuPool {
2129 workers: vec![worker.clone()],
2130 }),
2131 8,
2132 );
2133 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2134
2135 let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
2136 let job = crate::state::GenerationJob {
2137 id: String::new(),
2138 request: fake_request("flux-dev:q4"),
2139 progress_tx: None,
2140 result_tx,
2141 output_dir: None,
2142 };
2143 queue.submit(job, 8).await.unwrap();
2144
2145 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2146 assert!(
2147 result_rx.try_recv().is_err(),
2148 "dispatcher should keep the job pending while all workers are degraded"
2149 );
2150 assert!(
2151 worker_rx.try_recv().is_err(),
2152 "degraded worker must not receive work before recovery"
2153 );
2154
2155 worker.consecutive_failures.store(0, Ordering::SeqCst);
2156 *worker.degraded_until.write().unwrap() = None;
2157
2158 let dispatched = worker_rx
2159 .recv_timeout(std::time::Duration::from_secs(1))
2160 .expect("queued job should dispatch once a worker recovers");
2161 assert_eq!(dispatched.model, "flux-dev:q4");
2162
2163 drop(job_tx);
2164 dispatcher.abort();
2165 }
2166
2167 #[tokio::test]
2174 async fn cache_take_on_vanished_engine_returns_none_not_panic() {
2175 use crate::model_cache::ModelCache;
2176 use mold_core::GenerateResponse;
2177 use mold_inference::InferenceEngine;
2178
2179 struct StubEngine(&'static str);
2180 impl InferenceEngine for StubEngine {
2181 fn generate(&mut self, _r: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
2182 unimplemented!()
2183 }
2184 fn model_name(&self) -> &str {
2185 self.0
2186 }
2187 fn is_loaded(&self) -> bool {
2188 true
2189 }
2190 fn load(&mut self) -> anyhow::Result<()> {
2191 Ok(())
2192 }
2193 }
2194
2195 let mut cache = ModelCache::new(3);
2196 assert!(cache.take("vanished-model").is_none());
2199
2200 cache.insert(Box::new(StubEngine("present-model")), 0);
2204 let first = cache.take("present-model");
2205 assert!(first.is_some());
2206 assert!(
2207 cache.take("present-model").is_none(),
2208 "double-take must return None"
2209 );
2210 }
2211
2212 fn buf_job(model: &str) -> BufferedJob {
2213 let (tx, _rx) = tokio::sync::oneshot::channel();
2214 BufferedJob::new(crate::state::GenerationJob {
2215 id: String::new(),
2216 request: fake_request(model),
2217 progress_tx: None,
2218 result_tx: tx,
2219 output_dir: None,
2220 })
2221 }
2222
2223 #[test]
2224 fn pick_next_job_picks_head_when_head_model_loaded() {
2225 use std::collections::{HashSet, VecDeque};
2226 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2227 buffer.push_back(buf_job("a"));
2228 buffer.push_back(buf_job("b"));
2229 buffer.push_back(buf_job("a"));
2230 let loaded: HashSet<String> = ["a".to_string()].into_iter().collect();
2231 let picked = pick_next_job(&mut buffer, &loaded, 3);
2232 assert_eq!(picked.request.model, "a");
2233 assert_eq!(buffer.len(), 2);
2234 assert_eq!(buffer.front().unwrap().job.request.model, "b");
2235 assert_eq!(
2236 buffer.front().unwrap().deferred,
2237 0,
2238 "head shouldn't be deferred when picker chose the head itself"
2239 );
2240 }
2241
2242 #[test]
2243 fn pick_next_job_picks_non_head_when_only_non_head_model_loaded() {
2244 use std::collections::{HashSet, VecDeque};
2245 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2246 buffer.push_back(buf_job("a"));
2247 buffer.push_back(buf_job("b"));
2248 buffer.push_back(buf_job("a"));
2249 let loaded: HashSet<String> = ["b".to_string()].into_iter().collect();
2250 let picked = pick_next_job(&mut buffer, &loaded, 3);
2251 assert_eq!(picked.request.model, "b");
2252 assert_eq!(buffer.len(), 2);
2253 assert_eq!(buffer.front().unwrap().job.request.model, "a");
2255 assert_eq!(buffer.front().unwrap().deferred, 1);
2256 }
2257
2258 #[test]
2259 fn pick_next_job_force_dispatches_head_after_max_deferrals() {
2260 use std::collections::{HashSet, VecDeque};
2261 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2262 let mut head = buf_job("a");
2263 head.deferred = 3;
2264 buffer.push_back(head);
2265 buffer.push_back(buf_job("b"));
2266 let loaded: HashSet<String> = ["b".to_string()].into_iter().collect();
2268 let picked = pick_next_job(&mut buffer, &loaded, 3);
2269 assert_eq!(picked.request.model, "a");
2270 assert_eq!(buffer.len(), 1);
2271 assert_eq!(buffer.front().unwrap().job.request.model, "b");
2272 }
2273
2274 #[test]
2275 fn pick_next_job_falls_back_to_head_when_nothing_loaded() {
2276 use std::collections::{HashSet, VecDeque};
2277 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2278 buffer.push_back(buf_job("a"));
2279 buffer.push_back(buf_job("b"));
2280 let loaded: HashSet<String> = HashSet::new();
2281 let picked = pick_next_job(&mut buffer, &loaded, 3);
2282 assert_eq!(picked.request.model, "a");
2283 }
2284
2285 #[test]
2289 fn pick_next_job_max_deferrals_zero_picks_head_even_when_non_head_loaded() {
2290 use std::collections::{HashSet, VecDeque};
2291 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2292 buffer.push_back(buf_job("b")); buffer.push_back(buf_job("a")); let loaded: HashSet<String> = ["a".to_string()].into_iter().collect();
2295 let picked = pick_next_job(&mut buffer, &loaded, 0);
2296 assert_eq!(
2297 picked.request.model, "b",
2298 "max_deferrals=0 must force FIFO — head must win even when only the non-head model is loaded"
2299 );
2300 assert_eq!(buffer.len(), 1);
2301 assert_eq!(buffer.front().unwrap().job.request.model, "a");
2302 }
2303
2304 #[test]
2308 fn pick_next_job_max_deferrals_zero_with_empty_loaded_picks_head() {
2309 use std::collections::{HashSet, VecDeque};
2310 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2311 buffer.push_back(buf_job("a")); buffer.push_back(buf_job("b"));
2313 let loaded: HashSet<String> = HashSet::new();
2314 let picked = pick_next_job(&mut buffer, &loaded, 0);
2315 assert_eq!(picked.request.model, "a");
2316 assert_eq!(buffer.len(), 1);
2317 assert_eq!(buffer.front().unwrap().job.request.model, "b");
2318 }
2319
2320 #[test]
2325 fn pick_next_job_picks_front_most_match_when_multiple_loaded() {
2326 use std::collections::{HashSet, VecDeque};
2327 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2328 buffer.push_back(buf_job("a"));
2329 buffer.push_back(buf_job("b"));
2330 buffer.push_back(buf_job("a"));
2331 buffer.push_back(buf_job("b"));
2332 let loaded: HashSet<String> = ["a".to_string(), "b".to_string()].into_iter().collect();
2333 let picked = pick_next_job(&mut buffer, &loaded, 3);
2334 assert_eq!(
2335 picked.request.model, "a",
2336 "front-most match wins (the first `a`), not the loaded model with the most copies later in the buffer"
2337 );
2338 assert_eq!(buffer.len(), 3);
2342 let remaining: Vec<&str> = buffer
2343 .iter()
2344 .map(|b| b.job.request.model.as_str())
2345 .collect();
2346 assert_eq!(remaining, vec!["b", "a", "b"]);
2347 assert_eq!(buffer.front().unwrap().deferred, 0);
2348 }
2349
2350 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2354 async fn queue_dispatcher_reorders_interleaved_jobs_to_minimize_swaps() {
2355 let (worker, worker_rx) = test_worker(0, 8);
2356 {
2359 let mut cache = worker.model_cache.lock().unwrap();
2360 struct Engine(&'static str);
2361 impl mold_inference::InferenceEngine for Engine {
2362 fn generate(
2363 &mut self,
2364 _r: &GenerateRequest,
2365 ) -> anyhow::Result<mold_core::GenerateResponse> {
2366 unimplemented!()
2367 }
2368 fn model_name(&self) -> &str {
2369 self.0
2370 }
2371 fn is_loaded(&self) -> bool {
2372 true
2373 }
2374 fn load(&mut self) -> anyhow::Result<()> {
2375 Ok(())
2376 }
2377 }
2378 cache.insert(Box::new(Engine("a")), 0);
2379 }
2380
2381 let (job_tx, job_rx) = tokio::sync::mpsc::channel(8);
2382 let queue = QueueHandle::new(job_tx.clone());
2383 let state = crate::state::AppState::empty(
2384 mold_core::Config::default(),
2385 queue.clone(),
2386 Arc::new(GpuPool {
2387 workers: vec![worker.clone()],
2388 }),
2389 8,
2390 );
2391
2392 let mut result_rxs = Vec::new();
2395 for model in ["a", "b", "a", "b"] {
2396 let (tx, rx) = tokio::sync::oneshot::channel();
2397 let job = crate::state::GenerationJob {
2398 id: String::new(),
2399 request: fake_request(model),
2400 progress_tx: None,
2401 result_tx: tx,
2402 output_dir: None,
2403 };
2404 queue.submit(job, 8).await.unwrap();
2405 result_rxs.push(rx);
2406 }
2407
2408 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2409
2410 let mut order = Vec::new();
2411 for _ in 0..4 {
2412 let dispatched = worker_rx
2413 .recv_timeout(std::time::Duration::from_secs(2))
2414 .expect("worker should receive the dispatched job");
2415 order.push(dispatched.model);
2416 }
2417 drop(job_tx);
2418 dispatcher.abort();
2419
2420 assert_eq!(
2421 order,
2422 vec![
2423 "a".to_string(),
2424 "a".to_string(),
2425 "b".to_string(),
2426 "b".to_string(),
2427 ],
2428 "lookahead reorder should batch all `a` jobs together before swapping to `b`"
2429 );
2430 }
2431
2432 #[tokio::test]
2439 async fn top_up_buffer_never_exceeds_capacity() {
2440 use std::collections::VecDeque;
2441 let (job_tx, mut job_rx) = tokio::sync::mpsc::channel::<GenerationJob>(32);
2442
2443 for i in 0..10 {
2446 let (tx, _rx) = tokio::sync::oneshot::channel();
2447 let job = GenerationJob {
2448 id: String::new(),
2449 request: fake_request(&format!("model-{i}")),
2450 progress_tx: None,
2451 result_tx: tx,
2452 output_dir: None,
2453 };
2454 job_tx.send(job).await.unwrap();
2455 }
2456
2457 let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(4);
2459 top_up_buffer(&mut buffer, &mut job_rx, 4);
2460 assert_eq!(
2461 buffer.len(),
2462 4,
2463 "top_up_buffer must cap at buffer_size, leaving the rest in the channel"
2464 );
2465
2466 while buffer.pop_front().is_some() {}
2469 top_up_buffer(&mut buffer, &mut job_rx, 4);
2470 assert_eq!(buffer.len(), 4);
2471 let names: Vec<&str> = buffer
2472 .iter()
2473 .map(|b| b.job.request.model.as_str())
2474 .collect();
2475 assert_eq!(
2476 names,
2477 vec!["model-4", "model-5", "model-6", "model-7"],
2478 "second top-up must drain the next FIFO window from the channel"
2479 );
2480
2481 drop(job_tx);
2484 while buffer.pop_front().is_some() {}
2485 top_up_buffer(&mut buffer, &mut job_rx, 4);
2486 assert_eq!(
2487 buffer.len(),
2488 2,
2489 "top_up_buffer drains the channel tail when fewer jobs than capacity remain"
2490 );
2491 let names: Vec<&str> = buffer
2492 .iter()
2493 .map(|b| b.job.request.model.as_str())
2494 .collect();
2495 assert_eq!(names, vec!["model-8", "model-9"]);
2496 }
2497
2498 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2504 async fn queue_dispatcher_dispatches_all_jobs_when_submission_exceeds_buffer() {
2505 let (worker, worker_rx) = test_worker(0, 4);
2506 let (job_tx, job_rx) = tokio::sync::mpsc::channel(32);
2507 let queue = QueueHandle::new(job_tx.clone());
2508 let state = crate::state::AppState::empty(
2509 mold_core::Config::default(),
2510 queue.clone(),
2511 Arc::new(GpuPool {
2512 workers: vec![worker.clone()],
2513 }),
2514 32,
2515 );
2516
2517 let drain_worker = worker.clone();
2523 let drainer = std::thread::spawn(move || {
2524 let mut order = Vec::new();
2525 while order.len() < 10 {
2526 match worker_rx.recv_timeout(std::time::Duration::from_secs(5)) {
2527 Ok(j) => {
2528 drain_worker.in_flight.fetch_sub(1, Ordering::SeqCst);
2529 order.push(j.model);
2530 }
2531 Err(e) => panic!("drain stalled at {:?}: {e:?}", order),
2532 }
2533 }
2534 order
2535 });
2536
2537 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2538
2539 let mut held_rxs = Vec::new();
2545 for i in 0..10 {
2546 let (tx, rx) = tokio::sync::oneshot::channel();
2547 held_rxs.push(rx);
2548 let job = crate::state::GenerationJob {
2549 id: String::new(),
2550 request: fake_request(&format!("model-{i}")),
2551 progress_tx: None,
2552 result_tx: tx,
2553 output_dir: None,
2554 };
2555 queue.submit(job, 32).await.unwrap();
2556 }
2557
2558 let order = drainer.join().expect("drainer thread panic");
2559 drop(job_tx);
2560 dispatcher.abort();
2561
2562 let expected: Vec<String> = (0..10).map(|i| format!("model-{i}")).collect();
2563 assert_eq!(
2564 order, expected,
2565 "10 distinct jobs must come out in FIFO across buffer rotations"
2566 );
2567 }
2568
2569 static QUEUE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2571
2572 fn with_queue_env<R>(name: &str, value: Option<&str>, f: impl FnOnce() -> R) -> R {
2573 let _g = QUEUE_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2574 let prev = std::env::var(name).ok();
2575 match value {
2576 Some(v) => std::env::set_var(name, v),
2577 None => std::env::remove_var(name),
2578 }
2579 let out = f();
2580 match prev {
2581 Some(v) => std::env::set_var(name, v),
2582 None => std::env::remove_var(name),
2583 }
2584 out
2585 }
2586
2587 #[test]
2588 fn resolve_lookahead_buffer_uses_default_when_env_missing() {
2589 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, None, resolve_lookahead_buffer);
2590 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2591 }
2592
2593 #[test]
2594 fn resolve_lookahead_buffer_honors_env_within_range() {
2595 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("4"), resolve_lookahead_buffer);
2596 assert_eq!(n, 4);
2597 }
2598
2599 #[test]
2600 fn resolve_lookahead_buffer_falls_back_when_out_of_range() {
2601 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("0"), resolve_lookahead_buffer);
2603 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2604 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("999"), resolve_lookahead_buffer);
2605 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2606 }
2607
2608 #[test]
2609 fn resolve_lookahead_buffer_falls_back_when_unparseable() {
2610 let n = with_queue_env(
2611 LOOKAHEAD_BUFFER_ENV,
2612 Some("not-a-number"),
2613 resolve_lookahead_buffer,
2614 );
2615 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2616 }
2617
2618 #[test]
2619 fn resolve_max_deferrals_uses_default_when_env_missing() {
2620 let n = with_queue_env(MAX_DEFERRALS_ENV, None, resolve_max_deferrals);
2621 assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2622 }
2623
2624 #[test]
2625 fn resolve_max_deferrals_honors_env_within_range() {
2626 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("0"), resolve_max_deferrals);
2628 assert_eq!(n, 0);
2629 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("32"), resolve_max_deferrals);
2630 assert_eq!(n, 32);
2631 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("5"), resolve_max_deferrals);
2632 assert_eq!(n, 5);
2633 }
2634
2635 #[test]
2636 fn resolve_max_deferrals_falls_back_when_out_of_range() {
2637 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("999"), resolve_max_deferrals);
2638 assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2639 }
2640
2641 #[test]
2642 fn resolve_max_deferrals_falls_back_when_unparseable() {
2643 let n = with_queue_env(
2644 MAX_DEFERRALS_ENV,
2645 Some("not-a-number"),
2646 resolve_max_deferrals,
2647 );
2648 assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2649 }
2650
2651 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2652 async fn queue_dispatcher_honors_explicit_placement_gpu() {
2653 let (worker0, rx0) = test_worker(0, 1);
2654 let (worker1, rx1) = test_worker(1, 1);
2655 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2656 let queue = QueueHandle::new(job_tx.clone());
2657 let state = crate::state::AppState::empty(
2658 mold_core::Config::default(),
2659 queue.clone(),
2660 Arc::new(GpuPool {
2661 workers: vec![worker0, worker1],
2662 }),
2663 8,
2664 );
2665
2666 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state));
2667
2668 let mut request = fake_request("flux-dev:q4");
2669 request.placement = Some(mold_core::types::DevicePlacement {
2670 text_encoders: mold_core::types::DeviceRef::Auto,
2671 advanced: Some(mold_core::types::AdvancedPlacement {
2672 transformer: mold_core::types::DeviceRef::gpu(1),
2673 ..mold_core::types::AdvancedPlacement::default()
2674 }),
2675 });
2676
2677 let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
2678 let job = crate::state::GenerationJob {
2679 id: String::new(),
2680 request,
2681 progress_tx: None,
2682 result_tx,
2683 output_dir: None,
2684 };
2685 let _position = queue.submit(job, 8).await.unwrap();
2686
2687 let dispatched = rx1
2688 .recv_timeout(std::time::Duration::from_secs(1))
2689 .expect("explicit placement should route to gpu 1");
2690 assert_eq!(dispatched.model, "flux-dev:q4");
2691 assert!(rx0.try_recv().is_err(), "gpu 0 should not receive the job");
2692
2693 drop(job_tx);
2694 dispatcher.abort();
2695 }
2696
2697 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2698 async fn queue_dispatcher_records_auto_selected_gpu_before_worker_starts() {
2699 let (worker0, rx0) = test_worker(0, 1);
2700 let (worker1, rx1) = test_worker(1, 1);
2701 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2702 let queue = QueueHandle::new(job_tx.clone());
2703 let state = crate::state::AppState::empty(
2704 mold_core::Config::default(),
2705 queue.clone(),
2706 Arc::new(GpuPool {
2707 workers: vec![worker0, worker1],
2708 }),
2709 8,
2710 );
2711 state.job_registry.register("auto-job", "flux-dev:q4");
2712
2713 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2714
2715 let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
2716 let job = crate::state::GenerationJob {
2717 id: "auto-job".to_string(),
2718 request: fake_request("flux-dev:q4"),
2719 progress_tx: None,
2720 result_tx,
2721 output_dir: None,
2722 };
2723 let _position = queue.submit(job, 8).await.unwrap();
2724
2725 let (dispatched, ordinal) = match rx0.recv_timeout(std::time::Duration::from_secs(1)) {
2726 Ok(job) => (job, 0),
2727 Err(_) => (
2728 rx1.recv_timeout(std::time::Duration::from_secs(1))
2729 .expect("auto job should dispatch to one GPU"),
2730 1,
2731 ),
2732 };
2733 assert_eq!(dispatched.model, "flux-dev:q4");
2734 assert_eq!(
2735 state.job_registry.target_gpu("auto-job"),
2736 Some(Some(ordinal))
2737 );
2738
2739 drop(job_tx);
2740 dispatcher.abort();
2741 }
2742
2743 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2744 async fn paused_dispatcher_holds_new_jobs_until_resumed() {
2745 let (worker0, rx0) = test_worker(0, 1);
2746 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2747 let queue = QueueHandle::new(job_tx.clone());
2748 let state = crate::state::AppState::empty(
2749 mold_core::Config::default(),
2750 queue.clone(),
2751 Arc::new(GpuPool {
2752 workers: vec![worker0],
2753 }),
2754 8,
2755 );
2756
2757 assert!(state.queue_pause.pause());
2759 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2760
2761 let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
2762 let job = crate::state::GenerationJob {
2763 id: "paused-job".to_string(),
2764 request: fake_request("flux-dev:q4"),
2765 progress_tx: None,
2766 result_tx,
2767 output_dir: None,
2768 };
2769 let _position = queue.submit(job, 8).await.unwrap();
2770
2771 assert!(
2773 rx0.recv_timeout(std::time::Duration::from_millis(200))
2774 .is_err(),
2775 "paused dispatcher must not hand a job to a worker"
2776 );
2777
2778 assert!(state.queue_pause.resume());
2780 let dispatched = rx0
2781 .recv_timeout(std::time::Duration::from_secs(1))
2782 .expect("resumed dispatcher should dispatch the queued job");
2783 assert_eq!(dispatched.model, "flux-dev:q4");
2784
2785 drop(job_tx);
2786 dispatcher.abort();
2787 }
2788
2789 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2790 async fn pause_while_dispatcher_is_parked_on_an_empty_queue_still_holds_the_next_job() {
2791 let (worker0, rx0) = test_worker(0, 1);
2796 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2797 let queue = QueueHandle::new(job_tx.clone());
2798 let state = crate::state::AppState::empty(
2799 mold_core::Config::default(),
2800 queue.clone(),
2801 Arc::new(GpuPool {
2802 workers: vec![worker0],
2803 }),
2804 8,
2805 );
2806
2807 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2809 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2810
2811 assert!(state.queue_pause.pause());
2813 let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
2814 let job = crate::state::GenerationJob {
2815 id: "parked-job".to_string(),
2816 request: fake_request("flux-dev:q4"),
2817 progress_tx: None,
2818 result_tx,
2819 output_dir: None,
2820 };
2821 let _position = queue.submit(job, 8).await.unwrap();
2822
2823 assert!(
2824 rx0.recv_timeout(std::time::Duration::from_millis(200))
2825 .is_err(),
2826 "a job arriving while paused must not wake straight into dispatch"
2827 );
2828
2829 assert!(state.queue_pause.resume());
2830 let dispatched = rx0
2831 .recv_timeout(std::time::Duration::from_secs(1))
2832 .expect("resume should release the held job");
2833 assert_eq!(dispatched.model, "flux-dev:q4");
2834
2835 drop(job_tx);
2836 dispatcher.abort();
2837 }
2838}
2839
2840#[cfg(test)]
2841mod queue_pause_tests {
2842 use super::QueuePause;
2843 use std::time::Duration;
2844
2845 #[test]
2846 fn pause_and_resume_report_state_transitions() {
2847 let gate = QueuePause::new();
2848 assert!(!gate.is_paused());
2849 assert!(gate.pause(), "first pause flips state");
2850 assert!(gate.is_paused());
2851 assert!(!gate.pause(), "second pause is a no-op transition");
2852 assert!(gate.resume(), "first resume flips state");
2853 assert!(!gate.is_paused());
2854 assert!(!gate.resume(), "second resume is a no-op transition");
2855 }
2856
2857 #[tokio::test]
2858 async fn wait_if_paused_returns_immediately_when_not_paused() {
2859 let gate = QueuePause::new();
2860 tokio::time::timeout(Duration::from_secs(1), gate.wait_if_paused())
2862 .await
2863 .expect("wait_if_paused must not block when the gate is open");
2864 }
2865
2866 #[tokio::test]
2867 async fn wait_if_paused_blocks_until_resumed() {
2868 let gate = QueuePause::new();
2869 assert!(gate.pause());
2870
2871 let waiter = {
2872 let gate = gate.clone();
2873 tokio::spawn(async move { gate.wait_if_paused().await })
2874 };
2875
2876 tokio::time::sleep(Duration::from_millis(50)).await;
2878 assert!(!waiter.is_finished(), "waiter must block while paused");
2879
2880 assert!(gate.resume());
2882 tokio::time::timeout(Duration::from_secs(1), waiter)
2883 .await
2884 .expect("waiter must unblock within the timeout after resume")
2885 .expect("waiter task must not panic");
2886 }
2887
2888 #[tokio::test]
2889 async fn resume_wakes_every_gated_waiter() {
2890 let gate = QueuePause::new();
2892 assert!(gate.pause());
2893
2894 let waiters: Vec<_> = (0..3)
2895 .map(|_| {
2896 let gate = gate.clone();
2897 tokio::spawn(async move { gate.wait_if_paused().await })
2898 })
2899 .collect();
2900
2901 tokio::time::sleep(Duration::from_millis(50)).await;
2902 assert!(gate.resume());
2903
2904 for waiter in waiters {
2905 tokio::time::timeout(Duration::from_secs(1), waiter)
2906 .await
2907 .expect("every gated waiter must wake on a single resume")
2908 .expect("waiter task must not panic");
2909 }
2910 }
2911}