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::Ordering;
11use std::time::Instant;
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 = mold_core::time::now_epoch_ms_u64();
63
64 let mut active = state
65 .active_generation
66 .write()
67 .unwrap_or_else(|e| e.into_inner());
68 *active = Some(ActiveGenerationSnapshot {
69 model: model.to_string(),
70 prompt_sha256,
71 started_at_unix_ms,
72 started_at: Instant::now(),
73 });
74}
75
76fn clear_active_generation(state: &AppState) {
77 let mut active = state
78 .active_generation
79 .write()
80 .unwrap_or_else(|e| e.into_inner());
81 *active = None;
82}
83
84pub(crate) fn save_image_to_dir(
94 dir: &std::path::Path,
95 img: &mold_core::ImageData,
96 model: &str,
97 batch_size: u32,
98 metadata: Option<&OutputMetadata>,
99 generation_time_ms: Option<i64>,
100 db: Option<&MetadataDb>,
101) {
102 if let Err(e) = std::fs::create_dir_all(dir) {
103 tracing::warn!("failed to create output dir {}: {e}", dir.display());
104 return;
105 }
106 let timestamp_ms = mold_core::time::now_epoch_ms_u64();
107 let ext = img.format.to_string();
108 let filename =
109 mold_core::default_output_filename(model, timestamp_ms, &ext, batch_size, img.index);
110 let path = dir.join(&filename);
111 match std::fs::write(&path, &img.data) {
112 Ok(()) => tracing::info!("saved image to {}", path.display()),
113 Err(e) => {
114 tracing::warn!("failed to save image to {}: {e}", path.display());
115 return;
116 }
117 }
118 if let (Some(db), Some(meta)) = (db, metadata) {
119 mold_db::persist::record_saved_output(
120 db,
121 dir,
122 &filename,
123 &path,
124 &mold_db::persist::OutputRecordParams {
125 format: img.format,
126 metadata: meta,
127 source: RecordSource::Server,
128 generation_time_ms,
129 backend: Some(mold_inference::compiled_backend_label()),
130 },
131 );
132 }
133}
134
135#[allow(clippy::too_many_arguments)]
145pub(crate) fn save_video_to_dir(
146 dir: &std::path::Path,
147 bytes: &[u8],
148 gif_preview: &[u8],
149 format: OutputFormat,
150 model: &str,
151 metadata: &OutputMetadata,
152 generation_time_ms: Option<i64>,
153 db: Option<&MetadataDb>,
154) {
155 if let Err(e) = std::fs::create_dir_all(dir) {
156 tracing::warn!("failed to create output dir {}: {e}", dir.display());
157 return;
158 }
159 let ts = mold_core::time::now_epoch_ms_u64();
160 let ext = format.extension();
161 let filename = mold_core::default_output_filename(model, ts, ext, 1, 0);
162 let path = dir.join(&filename);
163 if let Err(e) = std::fs::write(&path, bytes) {
164 tracing::error!("failed to save video to {}: {e}", path.display());
165 return;
166 }
167 if !gif_preview.is_empty() {
168 save_video_preview_gif(&filename, gif_preview);
169 }
170 if let Some(db) = db {
171 mold_db::persist::record_saved_output(
172 db,
173 dir,
174 &filename,
175 &path,
176 &mold_db::persist::OutputRecordParams {
177 format,
178 metadata,
179 source: RecordSource::Server,
180 generation_time_ms,
181 backend: Some(mold_inference::compiled_backend_label()),
182 },
183 );
184 }
185}
186
187fn requested_post_upscale_model(req: &mold_core::GenerateRequest) -> Option<&str> {
188 req.upscale_model
189 .as_deref()
190 .map(str::trim)
191 .filter(|m| !m.is_empty())
192}
193
194pub(crate) fn apply_output_dimensions_to_metadata(metadata: &mut OutputMetadata, img: &ImageData) {
195 metadata.apply_output_dimensions(img.width, img.height);
196}
197
198pub(crate) fn apply_upscale_response_to_image_generation(
199 req: &mold_core::GenerateRequest,
200 response: &mut mold_core::GenerateResponse,
201 original: ImageData,
202 upscaled: mold_core::UpscaleResponse,
203) -> anyhow::Result<ImageData> {
204 if response.video.is_some() || requested_post_upscale_model(req).is_none() {
205 return Ok(original);
206 }
207 if upscaled.image.data.is_empty() {
208 anyhow::bail!("upscaler returned an empty image");
209 }
210 response.generation_time_ms = response
211 .generation_time_ms
212 .saturating_add(upscaled.upscale_time_ms);
213 Ok(ImageData {
214 index: original.index,
215 ..upscaled.image
216 })
217}
218
219async fn upscale_generated_image_on_single_worker(
220 state: &AppState,
221 req: &mold_core::GenerateRequest,
222 img: ImageData,
223 progress_tx: Option<&tokio::sync::mpsc::UnboundedSender<SseMessage>>,
224) -> Result<ImageData, String> {
225 let Some(upscale_model) = requested_post_upscale_model(req).map(str::to_string) else {
226 return Ok(img);
227 };
228 let model_name = mold_core::manifest::resolve_model_name(&upscale_model);
229 if let Some(tx) = progress_tx {
230 let _ = tx.send(SseMessage::Progress(SseProgressEvent::StageStart {
231 name: format!("Loading upscaler {model_name}"),
232 }));
233 }
234
235 let needs_pull = {
236 let config = state.config.read().await;
237 config
238 .models
239 .get(&model_name)
240 .and_then(|c| c.transformer.as_ref())
241 .is_none()
242 };
243 if needs_pull {
244 if mold_core::manifest::find_manifest(&model_name).is_none() {
245 return Err(format!("unknown upscaler model '{model_name}'"));
246 }
247 model_manager::pull_model(state, &model_name, None)
248 .await
249 .map_err(|e| format!("failed to pull upscaler model: {}", e.error))?;
250 }
251
252 let weights_path = {
253 let config = state.config.read().await;
254 config
255 .models
256 .get(&model_name)
257 .and_then(|c| c.transformer.as_ref())
258 .map(std::path::PathBuf::from)
259 }
260 .ok_or_else(|| format!("upscaler model '{model_name}' not configured after pull"))?;
261
262 let upscale_req = mold_core::UpscaleRequest {
263 model: model_name.clone(),
264 image: img.data.clone(),
265 output_format: img.format,
266 tile_size: None,
267 };
268 let upscaler_cache = state.upscaler_cache.clone();
269 let progress_tx_for_blocking = progress_tx.cloned();
270 let upscaled =
271 tokio::task::spawn_blocking(move || -> anyhow::Result<mold_core::UpscaleResponse> {
272 let mut cache = upscaler_cache.lock().unwrap_or_else(|e| e.into_inner());
273 let needs_new = cache.as_ref().is_none_or(|e| e.model_name() != model_name);
274 if needs_new {
275 let new_engine = mold_inference::create_upscale_engine(
276 model_name.clone(),
277 weights_path,
278 mold_inference::LoadStrategy::Eager,
279 0,
280 )?;
281 *cache = Some(new_engine);
282 }
283 let engine = cache.as_mut().unwrap();
284 if let Some(tx) = progress_tx_for_blocking {
285 engine.set_on_progress(Box::new(move |event| {
286 let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
287 }));
288 }
289 let result = engine.upscale(&upscale_req);
290 engine.clear_on_progress();
291 result
292 })
293 .await
294 .map_err(|e| format!("upscale task failed: {e}"))?
295 .map_err(|e| format!("upscale failed: {e}"))?;
296
297 let mut response = mold_core::GenerateResponse {
298 images: vec![],
299 video: None,
300 generation_time_ms: 0,
301 model: req.model.clone(),
302 seed_used: req.seed.unwrap_or(0),
303 gpu: None,
304 };
305 apply_upscale_response_to_image_generation(req, &mut response, img, upscaled)
306 .map_err(|e| format!("upscale failed: {e}"))
307}
308
309pub(crate) fn save_video_preview_gif(filename: &str, gif_bytes: &[u8]) {
318 let preview_dir = mold_core::Config::mold_dir()
319 .unwrap_or_else(|| std::path::PathBuf::from(".mold"))
320 .join("cache")
321 .join("previews");
322 save_video_preview_gif_to(&preview_dir, filename, gif_bytes);
323}
324
325fn save_video_preview_gif_to(preview_dir: &std::path::Path, filename: &str, gif_bytes: &[u8]) {
329 if let Err(e) = std::fs::create_dir_all(preview_dir) {
330 tracing::warn!(
331 "failed to create preview cache dir {}: {e}",
332 preview_dir.display()
333 );
334 return;
335 }
336 let preview_path = preview_dir.join(mold_core::media_paths::preview_gif_filename(filename));
337 if let Err(e) = std::fs::write(&preview_path, gif_bytes) {
338 tracing::warn!(
339 "failed to write preview gif {}: {e}",
340 preview_path.display()
341 );
342 }
343}
344
345pub(crate) fn build_sse_complete_event(
362 response: &mold_core::GenerateResponse,
363 img: &mold_core::ImageData,
364) -> SseCompleteEvent {
365 let b64 = base64::engine::general_purpose::STANDARD;
366 if let Some(ref video) = response.video {
367 SseCompleteEvent {
368 image: b64.encode(&video.data),
369 format: video.format,
370 width: video.width,
371 height: video.height,
372 seed_used: response.seed_used,
373 generation_time_ms: response.generation_time_ms,
374 model: response.model.clone(),
375 video_frames: Some(video.frames),
376 video_fps: Some(video.fps),
377 video_thumbnail: Some(b64.encode(&video.thumbnail)),
378 video_gif_preview: if video.gif_preview.is_empty() {
379 None
380 } else {
381 Some(b64.encode(&video.gif_preview))
382 },
383 video_has_audio: video.has_audio,
384 video_duration_ms: video.duration_ms,
385 video_audio_sample_rate: video.audio_sample_rate,
386 video_audio_channels: video.audio_channels,
387 gpu: response.gpu,
388 }
389 } else {
390 SseCompleteEvent {
391 image: b64.encode(&img.data),
392 format: img.format,
393 width: img.width,
394 height: img.height,
395 seed_used: response.seed_used,
396 generation_time_ms: response.generation_time_ms,
397 model: response.model.clone(),
398 video_frames: None,
399 video_fps: None,
400 video_thumbnail: None,
401 video_gif_preview: None,
402 video_has_audio: false,
403 video_duration_ms: None,
404 video_audio_sample_rate: None,
405 video_audio_channels: None,
406 gpu: response.gpu,
407 }
408 }
409}
410
411pub async fn run_queue_worker(
416 mut job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
417 state: AppState,
418) {
419 tracing::debug!("generation queue worker started");
420 let buffer_size = resolve_lookahead_buffer();
421 let max_deferrals = resolve_max_deferrals();
422 let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(buffer_size);
423
424 loop {
425 if buffer.is_empty() {
426 match job_rx.recv().await {
427 Some(j) => buffer.push_back(BufferedJob::new(j)),
428 None => break,
429 }
430 }
431 top_up_buffer(&mut buffer, &mut job_rx, buffer_size);
433
434 let loaded = single_gpu_loaded_models(&state).await;
435 let job = pick_next_job(&mut buffer, &loaded, max_deferrals);
436 let job_id = job.id.clone();
437
438 #[cfg(feature = "metrics")]
439 crate::metrics::record_queue_depth(state.queue.pending());
440 process_job(&state, job).await;
441 state.queue.decrement();
442 state.job_registry.remove(&job_id);
446 #[cfg(feature = "metrics")]
447 crate::metrics::record_queue_depth(state.queue.pending());
448 }
449 tracing::info!("generation queue worker shutting down");
450}
451
452async fn single_gpu_loaded_models(state: &AppState) -> std::collections::HashSet<String> {
453 let mut set = std::collections::HashSet::new();
454 let cache = state.model_cache.lock().await;
455 if let Some(name) = cache.active_model() {
456 set.insert(name.to_string());
457 }
458 set
459}
460
461fn multi_gpu_loaded_models(state: &AppState) -> std::collections::HashSet<String> {
467 let mut set = std::collections::HashSet::new();
468 for worker in &state.gpu_pool.workers {
469 if let Ok(active_gen) = worker.active_generation.read() {
470 if let Some(g) = active_gen.as_ref() {
471 set.insert(g.model.clone());
472 }
473 }
474 if let Ok(cache) = worker.model_cache.lock() {
475 if let Some(name) = cache.active_model() {
476 set.insert(name.to_string());
477 }
478 }
479 }
480 set
481}
482
483pub(crate) struct BufferedJob {
487 pub(crate) job: GenerationJob,
488 pub(crate) deferred: usize,
489}
490
491impl BufferedJob {
492 fn new(job: GenerationJob) -> Self {
493 Self { job, deferred: 0 }
494 }
495}
496
497pub(crate) fn top_up_buffer(
503 buffer: &mut VecDeque<BufferedJob>,
504 job_rx: &mut tokio::sync::mpsc::Receiver<GenerationJob>,
505 buffer_size: usize,
506) {
507 while buffer.len() < buffer_size {
508 match job_rx.try_recv() {
509 Ok(j) => buffer.push_back(BufferedJob::new(j)),
510 Err(_) => break,
511 }
512 }
513}
514
515pub(crate) fn pick_next_job(
525 buffer: &mut VecDeque<BufferedJob>,
526 loaded: &std::collections::HashSet<String>,
527 max_deferrals: usize,
528) -> GenerationJob {
529 debug_assert!(
530 !buffer.is_empty(),
531 "pick_next_job requires non-empty buffer"
532 );
533
534 if let Some(head) = buffer.pop_front_if(|head| head.deferred >= max_deferrals) {
536 return head.job;
537 }
538
539 let pick_idx = buffer
541 .iter()
542 .position(|b| loaded.contains(&b.job.request.model))
543 .unwrap_or(0);
544
545 if pick_idx > 0 {
546 for (i, b) in buffer.iter_mut().enumerate() {
547 if i < pick_idx {
548 b.deferred += 1;
549 }
550 }
551 let model = buffer[pick_idx].job.request.model.clone();
552 tracing::debug!(
553 picked_model = %model,
554 head_model = %buffer.front().map(|b| b.job.request.model.as_str()).unwrap_or(""),
555 picked_index = pick_idx,
556 "queue reorder picked non-head job"
557 );
558 #[cfg(feature = "metrics")]
559 crate::metrics::record_queue_reorder();
560 }
561
562 buffer.remove(pick_idx).expect("pick_idx in range").job
563}
564
565pub(crate) const DEFAULT_LOOKAHEAD_BUFFER: usize = 8;
566pub(crate) const DEFAULT_MAX_DEFERRALS: usize = 3;
567pub(crate) const LOOKAHEAD_BUFFER_ENV: &str = "MOLD_QUEUE_LOOKAHEAD_BUFFER";
568pub(crate) const MAX_DEFERRALS_ENV: &str = "MOLD_QUEUE_MAX_DEFERRALS";
569const LOOKAHEAD_BUFFER_LOWER: usize = 1;
570const LOOKAHEAD_BUFFER_UPPER: usize = 64;
571const MAX_DEFERRALS_UPPER: usize = 32;
572
573pub(crate) fn resolve_lookahead_buffer() -> usize {
577 match std::env::var(LOOKAHEAD_BUFFER_ENV) {
578 Ok(raw) => match raw.trim().parse::<usize>() {
579 Ok(n) if (LOOKAHEAD_BUFFER_LOWER..=LOOKAHEAD_BUFFER_UPPER).contains(&n) => n,
580 Ok(n) => {
581 tracing::warn!(
582 env = LOOKAHEAD_BUFFER_ENV,
583 value = n,
584 lower = LOOKAHEAD_BUFFER_LOWER,
585 upper = LOOKAHEAD_BUFFER_UPPER,
586 "ignoring out-of-range queue lookahead buffer; using default"
587 );
588 DEFAULT_LOOKAHEAD_BUFFER
589 }
590 Err(e) => {
591 tracing::warn!(
592 env = LOOKAHEAD_BUFFER_ENV,
593 raw = %raw,
594 error = %e,
595 "ignoring unparseable queue lookahead buffer; using default"
596 );
597 DEFAULT_LOOKAHEAD_BUFFER
598 }
599 },
600 Err(_) => DEFAULT_LOOKAHEAD_BUFFER,
601 }
602}
603
604pub(crate) fn resolve_max_deferrals() -> usize {
607 match std::env::var(MAX_DEFERRALS_ENV) {
608 Ok(raw) => match raw.trim().parse::<usize>() {
609 Ok(n) if n <= MAX_DEFERRALS_UPPER => n,
610 Ok(n) => {
611 tracing::warn!(
612 env = MAX_DEFERRALS_ENV,
613 value = n,
614 upper = MAX_DEFERRALS_UPPER,
615 "ignoring out-of-range queue max-deferrals; using default"
616 );
617 DEFAULT_MAX_DEFERRALS
618 }
619 Err(e) => {
620 tracing::warn!(
621 env = MAX_DEFERRALS_ENV,
622 raw = %raw,
623 error = %e,
624 "ignoring unparseable queue max-deferrals; using default"
625 );
626 DEFAULT_MAX_DEFERRALS
627 }
628 },
629 Err(_) => DEFAULT_MAX_DEFERRALS,
630 }
631}
632
633async fn process_job(state: &AppState, job: GenerationJob) {
634 if job.result_tx.is_closed() {
636 tracing::debug!("skipping queued job — client disconnected");
637 return;
638 }
639
640 state.job_registry.mark_running(&job.id, None);
643
644 if let Some(ref tx) = job.progress_tx {
648 let _ = tx.send(SseMessage::Progress(SseProgressEvent::Queued {
649 position: 0,
650 id: job.id.clone(),
651 }));
652 }
653
654 let progress_callback = job.progress_tx.as_ref().map(|tx| {
656 let tx = tx.clone();
657 Arc::new(move |event: mold_inference::ProgressEvent| {
658 let _ = tx.send(SseMessage::Progress(progress_to_sse(event)));
659 }) as model_manager::EngineProgressCallback
660 });
661
662 let activation_hint = model_manager::activation_hint_for_request(state, &job.request).await;
663 let request_has_lora = model_manager::request_has_effective_lora(&job.request);
664 if let Err(api_err) = model_manager::ensure_model_ready(
665 state,
666 &job.request.model,
667 progress_callback,
668 activation_hint,
669 request_has_lora,
670 )
671 .await
672 {
673 let err_msg = api_err.error.clone();
674 if let Some(ref tx) = job.progress_tx {
675 let _ = tx.send(SseMessage::Error(SseErrorEvent {
676 message: err_msg.clone(),
677 }));
678 }
679 let _ = job.result_tx.send(Err(err_msg));
680 return;
681 }
682
683 #[cfg(target_os = "macos")]
685 if let Some(available) = mold_inference::device::available_system_memory_bytes() {
686 if available < 1_000_000_000 {
687 tracing::warn!(
688 available_mb = available / 1_000_000,
689 "low memory before inference — system may become unstable"
690 );
691 }
692 }
693
694 let taken = {
699 let mut cache = state.model_cache.lock().await;
700 cache.take(&job.request.model)
701 };
702 let Some(mut cached_engine) = taken else {
703 let err_msg = "no engine available after model readiness check".to_string();
704 if let Some(ref tx) = job.progress_tx {
705 let _ = tx.send(SseMessage::Error(SseErrorEvent {
706 message: err_msg.clone(),
707 }));
708 }
709 let _ = job.result_tx.send(Err(err_msg));
710 return;
711 };
712
713 let active_gen = state.active_generation.clone();
714 let gen_req = job.request.clone();
715 let progress_tx = job.progress_tx.clone();
716
717 set_active_generation(state, &job.request.model, &job.request.prompt);
718
719 let was_streaming = progress_tx.is_some();
724 if let Some(ref ptx) = progress_tx {
725 let ptx = ptx.clone();
726 cached_engine.engine.set_on_progress(Box::new(move |event| {
727 let _ = ptx.send(SseMessage::Progress(progress_to_sse(event)));
728 }));
729 } else {
730 cached_engine.engine.clear_on_progress();
731 }
732
733 #[cfg(feature = "metrics")]
734 let inference_start = Instant::now();
735 let rss_before = crate::resources::ram_snapshot().used_by_mold;
739 let join_result = tokio::task::spawn_blocking(move || {
743 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
744 cached_engine.engine.generate(&gen_req)
745 }));
746 if was_streaming {
747 cached_engine.engine.clear_on_progress();
748 }
749 (cached_engine, result)
750 })
751 .await;
752
753 let rss_after = crate::resources::ram_snapshot().used_by_mold;
754 let rss_delta = rss_after as i64 - rss_before as i64;
755 tracing::info!(
756 model = %job.request.model,
757 rss_before_mb = rss_before / 1_000_000,
758 rss_after_mb = rss_after / 1_000_000,
759 rss_delta_mb = rss_delta / 1_000_000,
760 "generation memory delta"
761 );
762
763 #[cfg(feature = "metrics")]
764 let inference_duration = inference_start.elapsed().as_secs_f64();
765
766 let result = match join_result {
775 Ok((cached_engine, panic_or_result)) => {
776 {
777 let mut cache = state.model_cache.lock().await;
778 cache.restore(cached_engine);
779 }
780 clear_active_generation(state);
781 Ok(panic_or_result)
782 }
783 Err(join_err) => {
784 {
785 let mut cache = state.model_cache.lock().await;
786 cache.clear_in_flight(&job.request.model);
787 }
788 clear_active_generation(state);
789 Err(join_err)
790 }
791 };
792
793 match result {
794 Ok(Ok(Ok(mut response))) => {
795 #[cfg(feature = "metrics")]
796 crate::metrics::record_generation(&job.request.model, inference_duration);
797
798 if response.images.is_empty() && response.video.is_none() {
799 let err_msg = "generation error: engine returned no images or video".to_string();
800 if let Some(ref tx) = job.progress_tx {
801 let _ = tx.send(SseMessage::Error(SseErrorEvent {
802 message: err_msg.clone(),
803 }));
804 }
805 let _ = job.result_tx.send(Err(err_msg));
806 return;
807 }
808 let mut img = if !response.images.is_empty() {
811 response.images.remove(0)
812 } else if let Some(ref video) = response.video {
813 ImageData {
814 data: video.thumbnail.clone(),
815 format: OutputFormat::Png,
816 width: video.width,
817 height: video.height,
818 index: 0,
819 }
820 } else {
821 unreachable!("checked above");
822 };
823 if response.video.is_none() && requested_post_upscale_model(&job.request).is_some() {
824 match upscale_generated_image_on_single_worker(
825 state,
826 &job.request,
827 img,
828 job.progress_tx.as_ref(),
829 )
830 .await
831 {
832 Ok(upscaled) => {
833 img = upscaled;
834 }
835 Err(err_msg) => {
836 if let Some(ref tx) = job.progress_tx {
837 let _ = tx.send(SseMessage::Error(SseErrorEvent {
838 message: err_msg.clone(),
839 }));
840 }
841 let _ = job.result_tx.send(Err(err_msg));
842 return;
843 }
844 }
845 }
846
847 if let Some(ref dir) = job.output_dir {
851 let dir = dir.clone();
852 let model = job.request.model.clone();
853 let batch_size = job.request.batch_size;
854 let generation_time_ms = response.generation_time_ms as i64;
855 let mut metadata = OutputMetadata::from_generate_request(
856 &job.request,
857 response.seed_used,
858 None,
859 mold_core::build_info::version_string(),
860 );
861 if response.video.is_none() {
862 apply_output_dimensions_to_metadata(&mut metadata, &img);
863 }
864 let db = state.metadata_db.clone();
865 if let Some(ref video) = response.video {
866 let video_data = video.data.clone();
867 let video_gif_preview = video.gif_preview.clone();
868 let video_format = video.format;
869 let video_metadata = metadata.clone();
870 tokio::task::spawn_blocking(move || {
871 save_video_to_dir(
872 &dir,
873 &video_data,
874 &video_gif_preview,
875 video_format,
876 &model,
877 &video_metadata,
878 Some(generation_time_ms),
879 db.as_ref().as_ref(),
880 );
881 });
882 } else {
883 let img_clone = img.clone();
884 let metadata_clone = metadata.clone();
885 tokio::task::spawn_blocking(move || {
886 save_image_to_dir(
887 &dir,
888 &img_clone,
889 &model,
890 batch_size,
891 Some(&metadata_clone),
892 Some(generation_time_ms),
893 db.as_ref().as_ref(),
894 );
895 });
896 }
897 }
898
899 if let Some(ref tx) = job.progress_tx {
901 let event = build_sse_complete_event(&response, &img);
902 let _ = tx.send(SseMessage::Complete(event));
903 }
904
905 let _ = job.result_tx.send(Ok(GenerationJobResult {
907 image: img,
908 response,
909 }));
910 }
911 Ok(Ok(Err(e))) => {
912 #[cfg(feature = "metrics")]
913 crate::metrics::record_generation_error(&job.request.model);
914
915 *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
916 tracing::error!("generation error: {e:#}");
917 let err_msg = format!("generation error: {}", clean_error_message(&e));
918 if let Some(ref tx) = job.progress_tx {
919 let _ = tx.send(SseMessage::Error(SseErrorEvent {
920 message: err_msg.clone(),
921 }));
922 }
923 let _ = job.result_tx.send(Err(err_msg));
924 }
925 Ok(Err(panic_payload)) => {
926 #[cfg(feature = "metrics")]
927 crate::metrics::record_generation_error(&job.request.model);
928
929 *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
930 let msg = panic_payload
931 .downcast_ref::<String>()
932 .map(|s| s.as_str())
933 .or_else(|| panic_payload.downcast_ref::<&str>().copied())
934 .unwrap_or("unknown panic");
935 tracing::error!("inference panicked: {msg}");
936 let err_msg = format!("inference panicked: {msg}");
937 if let Some(ref tx) = job.progress_tx {
938 let _ = tx.send(SseMessage::Error(SseErrorEvent {
939 message: err_msg.clone(),
940 }));
941 }
942 let _ = job.result_tx.send(Err(err_msg));
943 }
944 Err(join_err) => {
945 #[cfg(feature = "metrics")]
946 crate::metrics::record_generation_error(&job.request.model);
947
948 *active_gen.write().unwrap_or_else(|e| e.into_inner()) = None;
949 tracing::error!("inference task join error: {join_err:?}");
950 let err_msg = "inference task failed".to_string();
951 if let Some(ref tx) = job.progress_tx {
952 let _ = tx.send(SseMessage::Error(SseErrorEvent {
953 message: err_msg.clone(),
954 }));
955 }
956 let _ = job.result_tx.send(Err(err_msg));
957 }
958 }
959}
960
961pub async fn run_queue_dispatcher(
971 job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
972 state: AppState,
973) {
974 tracing::debug!("multi-GPU queue dispatcher started");
975 let buffer_size = resolve_lookahead_buffer();
976 let max_deferrals = resolve_max_deferrals();
977 run_queue_dispatcher_with_tuning(job_rx, state, buffer_size, max_deferrals).await;
978}
979
980async fn run_queue_dispatcher_with_tuning(
981 mut job_rx: tokio::sync::mpsc::Receiver<GenerationJob>,
982 state: AppState,
983 buffer_size: usize,
984 max_deferrals: usize,
985) {
986 let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(buffer_size);
987
988 loop {
989 if buffer.is_empty() {
990 match job_rx.recv().await {
991 Some(j) => buffer.push_back(BufferedJob::new(j)),
992 None => break,
993 }
994 }
995 top_up_buffer(&mut buffer, &mut job_rx, buffer_size);
996
997 let loaded = multi_gpu_loaded_models(&state);
998 let job = pick_next_job(&mut buffer, &loaded, max_deferrals);
999
1000 #[cfg(feature = "metrics")]
1001 crate::metrics::record_queue_depth(state.queue.pending());
1002
1003 let job_id = job.id.clone();
1004 let model_name = job.request.model.clone();
1005 let estimated_vram = estimate_model_vram(&model_name);
1006
1007 if let Some(err_msg) = crate::gpu_pool::model_unschedulable_message(&model_name) {
1008 tracing::warn!(model = %model_name, "{err_msg}");
1009 if let Some(tx) = job.progress_tx {
1010 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1011 message: err_msg.clone(),
1012 }));
1013 }
1014 let _ = job.result_tx.send(Err(err_msg));
1015 state.queue.decrement();
1016 state.job_registry.remove(&job_id);
1017 #[cfg(feature = "metrics")]
1018 crate::metrics::record_queue_depth(state.queue.pending());
1019 continue;
1020 }
1021
1022 let placement_gpu = match state
1023 .gpu_pool
1024 .resolve_explicit_placement_gpu(job.request.placement.as_ref())
1025 {
1026 Ok(ordinal) => ordinal,
1027 Err(err_msg) => {
1028 tracing::warn!(model = %model_name, "{err_msg}");
1029 if let Some(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 state.queue.decrement();
1036 state.job_registry.remove(&job_id);
1037 #[cfg(feature = "metrics")]
1038 crate::metrics::record_queue_depth(state.queue.pending());
1039 continue;
1040 }
1041 };
1042 let preferred_gpu = state
1043 .job_registry
1044 .target_gpu(&job_id)
1045 .flatten()
1046 .or(placement_gpu);
1047
1048 if job.result_tx.is_closed() {
1049 tracing::debug!(model = %model_name, "skipping queued multi-GPU job — client disconnected");
1050 state.queue.decrement();
1051 state.job_registry.remove(&job_id);
1052 #[cfg(feature = "metrics")]
1053 crate::metrics::record_queue_depth(state.queue.pending());
1054 continue;
1055 }
1056
1057 let mut gpu_job = Some(GpuJob {
1059 id: job.id.clone(),
1060 model: model_name.clone(),
1061 request: job.request,
1062 progress_tx: job.progress_tx,
1063 result_tx: job.result_tx,
1064 output_dir: job.output_dir,
1065 config: state.config.clone(),
1066 metadata_db: state.metadata_db.clone(),
1067 queue: state.queue.clone(),
1068 registry: state.job_registry.clone(),
1069 });
1070
1071 let mut skip: Vec<usize> = if preferred_gpu.is_none() {
1072 let failed = crate::gpu_pool::failed_ordinals_for_model(&model_name);
1073 if failed.len() < state.gpu_pool.worker_count() {
1074 failed
1075 } else {
1076 Vec::new()
1077 }
1078 } else {
1079 Vec::new()
1080 };
1081 let mut dispatched = false;
1082
1083 while !dispatched {
1084 if gpu_job
1085 .as_ref()
1086 .is_some_and(|pending| pending.result_tx.is_closed())
1087 {
1088 tracing::debug!(
1089 model = %model_name,
1090 "dropping queued multi-GPU job before dispatch — client disconnected"
1091 );
1092 state.queue.decrement();
1093 state.job_registry.remove(&job_id);
1094 break;
1095 }
1096
1097 let worker = if let Some(ordinal) = preferred_gpu {
1098 state.gpu_pool.worker_by_ordinal(ordinal)
1099 } else {
1100 state
1101 .gpu_pool
1102 .select_worker_excluding(&model_name, estimated_vram, &skip)
1103 };
1104
1105 let Some(worker) = worker else {
1106 if preferred_gpu.is_none() && state.gpu_pool.worker_count() > 0 {
1107 tracing::warn!(
1108 model = %model_name,
1109 "all GPU workers are temporarily unavailable; keeping job queued"
1110 );
1111 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1112 continue;
1113 }
1114 let rejected = gpu_job
1115 .take()
1116 .expect("gpu_job retained after failed dispatch");
1117 let err_msg = if state.gpu_pool.worker_count() == 0 {
1118 format!("no GPU available for model {model_name}")
1119 } else if let Some(ordinal) = preferred_gpu {
1120 format!("gpu:{ordinal} is not available for model {model_name}")
1121 } else {
1122 format!("no GPU worker available for model {model_name}")
1123 };
1124 tracing::error!(model = %model_name, "{err_msg}");
1125 if let Some(tx) = rejected.progress_tx {
1126 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1127 message: err_msg.clone(),
1128 }));
1129 }
1130 let _ = rejected.result_tx.send(Err(err_msg));
1131 state.queue.decrement();
1132 state.job_registry.remove(&job_id);
1133 break;
1134 };
1135
1136 worker.in_flight.fetch_add(1, Ordering::SeqCst);
1138 let pending = gpu_job.take().expect("gpu_job present in retry loop");
1139 if preferred_gpu.is_none() {
1140 let _ = state
1141 .job_registry
1142 .set_target_gpu(&job_id, Some(worker.gpu.ordinal));
1143 }
1144 match worker.job_tx.try_send(pending) {
1145 Ok(()) => {
1146 dispatched = true;
1147 }
1148 Err(std::sync::mpsc::TrySendError::Full(j)) => {
1149 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1150 if preferred_gpu.is_none() {
1151 let _ = state.job_registry.set_target_gpu(&job_id, None);
1152 }
1153 gpu_job = Some(j);
1154 if preferred_gpu.is_none() {
1155 skip.push(worker.gpu.ordinal);
1156 if skip.len() >= state.gpu_pool.worker_count().max(1) {
1157 skip.clear();
1158 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1159 }
1160 } else {
1161 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1162 }
1163 }
1164 Err(std::sync::mpsc::TrySendError::Disconnected(j)) => {
1165 worker.in_flight.fetch_sub(1, Ordering::SeqCst);
1166 if preferred_gpu.is_none() {
1167 let _ = state.job_registry.set_target_gpu(&job_id, None);
1168 }
1169 tracing::warn!(
1170 gpu = worker.gpu.ordinal,
1171 "GPU worker disconnected — retrying dispatch"
1172 );
1173 gpu_job = Some(j);
1174 if preferred_gpu.is_none() {
1175 skip.push(worker.gpu.ordinal);
1176 } else {
1177 let rejected = gpu_job.take().expect("gpu_job retained after disconnect");
1178 let err_msg = format!(
1179 "gpu:{} disconnected while dispatching model {model_name}",
1180 worker.gpu.ordinal
1181 );
1182 if let Some(tx) = rejected.progress_tx {
1183 let _ = tx.send(SseMessage::Error(SseErrorEvent {
1184 message: err_msg.clone(),
1185 }));
1186 }
1187 let _ = rejected.result_tx.send(Err(err_msg));
1188 state.queue.decrement();
1189 state.job_registry.remove(&job_id);
1190 break;
1191 }
1192 }
1193 }
1194 }
1195 #[cfg(feature = "metrics")]
1196 crate::metrics::record_queue_depth(state.queue.pending());
1197 }
1198 tracing::info!("multi-GPU queue dispatcher shutting down");
1199}
1200
1201pub fn estimate_model_vram(model_name: &str) -> u64 {
1203 let lower = model_name.to_lowercase();
1206 if lower.contains("flux2")
1207 && lower.contains("9b")
1208 && (lower.contains(":bf16") || lower.contains(":fp16"))
1209 {
1210 32_000_000_000 } else if lower.contains(":q4") {
1212 6_000_000_000 } else if lower.contains(":q8") || lower.contains(":fp8") {
1214 12_000_000_000 } else if lower.contains(":bf16") || lower.contains(":fp16") {
1216 24_000_000_000 } else if lower.contains("sd15") || lower.contains("sd1.5") {
1218 4_000_000_000 } else {
1220 8_000_000_000
1222 }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::*;
1228 use crate::gpu_pool::{GpuPool, GpuWorker};
1229 use crate::model_cache::ModelCache;
1230 use crate::state::QueueHandle;
1231 use mold_core::{GenerateRequest, ImageData, ModelConfig, OutputFormat};
1232 use mold_db::MetadataDb;
1233 use mold_inference::device::DiscoveredGpu;
1234 use mold_inference::shared_pool::SharedPool;
1235 use std::sync::atomic::AtomicUsize;
1236 use std::sync::{Arc, Mutex, RwLock};
1237 use tempfile::TempDir;
1238
1239 fn fake_request(model: &str) -> GenerateRequest {
1242 GenerateRequest {
1243 prompt: "a cat".to_string(),
1244 negative_prompt: None,
1245 model: model.to_string(),
1246 width: 512,
1247 height: 512,
1248 steps: 4,
1249 guidance: 3.5,
1250 seed: Some(7),
1251 batch_size: 1,
1252 output_format: Some(OutputFormat::Png),
1253 embed_metadata: None,
1254 scheduler: None,
1255 cfg_plus: None,
1256 source_image: None,
1257 edit_images: None,
1258 strength: 0.75,
1259 mask_image: None,
1260 control_image: None,
1261 control_model: None,
1262 control_scale: 1.0,
1263 expand: None,
1264 original_prompt: None,
1265 lora: None,
1266 frames: None,
1267 fps: None,
1268 upscale_model: None,
1269 gif_preview: false,
1270 enable_audio: None,
1271 audio_file: None,
1272 audio_file_path: None,
1273 source_video: None,
1274 source_video_path: None,
1275 keyframes: None,
1276 pipeline: None,
1277 loras: None,
1278 retake_range: None,
1279 spatial_upscale: None,
1280 temporal_upscale: None,
1281 placement: None,
1282 }
1283 }
1284
1285 fn fake_image() -> ImageData {
1286 ImageData {
1287 data: vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
1290 format: OutputFormat::Png,
1291 width: 512,
1292 height: 512,
1293 index: 0,
1294 }
1295 }
1296
1297 fn test_worker(
1298 ordinal: usize,
1299 channel_size: usize,
1300 ) -> (
1301 Arc<GpuWorker>,
1302 std::sync::mpsc::Receiver<crate::gpu_pool::GpuJob>,
1303 ) {
1304 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(channel_size);
1305 let worker = Arc::new(GpuWorker {
1306 gpu: DiscoveredGpu {
1307 ordinal,
1308 name: format!("gpu{ordinal}"),
1309 total_vram_bytes: 24_000_000_000,
1310 free_vram_bytes: 24_000_000_000,
1311 },
1312 model_cache: Arc::new(Mutex::new(ModelCache::new(3))),
1313 active_generation: Arc::new(RwLock::new(None)),
1314 model_load_lock: Arc::new(Mutex::new(())),
1315 shared_pool: Arc::new(Mutex::new(SharedPool::new())),
1316 in_flight: AtomicUsize::new(0),
1317 consecutive_failures: AtomicUsize::new(0),
1318 degraded_until: RwLock::new(None),
1319 job_tx,
1320 });
1321 (worker, job_rx)
1322 }
1323
1324 fn empty_test_state(config: mold_core::Config) -> crate::state::AppState {
1325 crate::state::AppState::empty(
1326 config,
1327 QueueHandle::new(tokio::sync::mpsc::channel(1).0),
1328 crate::state::AppState::empty_gpu_pool(),
1329 200,
1330 )
1331 }
1332
1333 #[test]
1334 fn save_image_to_dir_writes_file_and_creates_missing_dir() {
1335 let tmp = TempDir::new().unwrap();
1336 let nested = tmp.path().join("sub/output");
1337 assert!(!nested.exists());
1338
1339 save_image_to_dir(&nested, &fake_image(), "flux-dev:q4", 1, None, None, None);
1340
1341 assert!(nested.exists(), "save should mkdir -p");
1342 let entries: Vec<_> = std::fs::read_dir(&nested).unwrap().collect();
1343 assert_eq!(entries.len(), 1);
1344 let name = entries[0].as_ref().unwrap().file_name();
1345 let name_str = name.to_string_lossy();
1346 assert!(name_str.starts_with("mold-flux-dev-q4-"), "{name_str}");
1348 assert!(name_str.ends_with(".png"), "{name_str}");
1349 }
1350
1351 #[test]
1352 fn save_image_to_dir_includes_batch_index_when_batch_size_gt_1() {
1353 let tmp = TempDir::new().unwrap();
1354 let mut img = fake_image();
1355 img.index = 3;
1356 img.format = OutputFormat::Jpeg;
1357 img.data = vec![0xFF, 0xD8, 0xFF, 0xE0]; save_image_to_dir(tmp.path(), &img, "sdxl", 4, None, None, None);
1360
1361 let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1362 let name = entries[0]
1363 .as_ref()
1364 .unwrap()
1365 .file_name()
1366 .to_string_lossy()
1367 .to_string();
1368 assert!(
1369 name.contains("-3.jpeg"),
1370 "expected batch index suffix: {name}"
1371 );
1372 }
1373
1374 #[test]
1375 fn save_image_to_dir_upserts_metadata_row_when_db_provided() {
1376 let tmp = TempDir::new().unwrap();
1377 let db = MetadataDb::open_in_memory().unwrap();
1378 let req = fake_request("flux-dev:q4");
1379 let meta = OutputMetadata::from_generate_request(&req, 42, None, "test-version");
1380
1381 save_image_to_dir(
1382 tmp.path(),
1383 &fake_image(),
1384 "flux-dev:q4",
1385 1,
1386 Some(&meta),
1387 Some(1234),
1388 Some(&db),
1389 );
1390
1391 let rows = db.list(Some(tmp.path())).unwrap();
1392 assert_eq!(rows.len(), 1, "exactly one DB row for the saved file");
1393 let rec = &rows[0];
1394 assert_eq!(rec.metadata.prompt, "a cat");
1395 assert_eq!(rec.metadata.seed, 42);
1396 assert_eq!(rec.metadata.version, "test-version");
1397 assert_eq!(rec.format, OutputFormat::Png);
1398 assert_eq!(rec.generation_time_ms, Some(1234));
1399 assert!(rec.file_size_bytes.unwrap_or(0) > 0);
1401 }
1402
1403 #[test]
1404 fn save_image_to_dir_skips_db_when_metadata_is_none() {
1405 let tmp = TempDir::new().unwrap();
1406 let db = MetadataDb::open_in_memory().unwrap();
1407
1408 save_image_to_dir(
1409 tmp.path(),
1410 &fake_image(),
1411 "flux-dev:q4",
1412 1,
1413 None, Some(1234),
1415 Some(&db),
1416 );
1417
1418 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 1);
1421 assert_eq!(db.list(None).unwrap().len(), 0);
1422 }
1423
1424 #[test]
1425 fn save_image_to_dir_invalid_path_does_not_panic() {
1426 save_image_to_dir(
1429 std::path::Path::new("/dev/null/cant-mkdir-here"),
1430 &fake_image(),
1431 "test",
1432 1,
1433 None,
1434 None,
1435 None,
1436 );
1437 }
1438
1439 #[test]
1440 fn save_video_to_dir_writes_mp4_and_records_metadata() {
1441 let tmp = TempDir::new().unwrap();
1442 let db = MetadataDb::open_in_memory().unwrap();
1443 let mut req = fake_request("ltx-video:fp16");
1444 req.frames = Some(25);
1445 req.fps = Some(24);
1446 let meta = OutputMetadata::from_generate_request(&req, 99, None, "test-version");
1447
1448 let bytes = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom".to_vec();
1451
1452 save_video_to_dir(
1453 tmp.path(),
1454 &bytes,
1455 b"",
1456 OutputFormat::Mp4,
1457 "ltx-video:fp16",
1458 &meta,
1459 Some(5000),
1460 Some(&db),
1461 );
1462
1463 let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1464 assert_eq!(entries.len(), 1);
1465 let name = entries[0]
1466 .as_ref()
1467 .unwrap()
1468 .file_name()
1469 .to_string_lossy()
1470 .to_string();
1471 assert!(name.starts_with("mold-ltx-video-fp16-"), "{name}");
1472 assert!(name.ends_with(".mp4"), "{name}");
1473
1474 let rows = db.list(Some(tmp.path())).unwrap();
1475 assert_eq!(rows.len(), 1);
1476 assert_eq!(rows[0].format, OutputFormat::Mp4);
1477 assert_eq!(rows[0].metadata.frames, Some(25));
1478 assert_eq!(rows[0].metadata.fps, Some(24));
1479 assert_eq!(rows[0].generation_time_ms, Some(5000));
1480 }
1481
1482 #[test]
1483 fn save_video_to_dir_without_db_still_writes_file() {
1484 let tmp = TempDir::new().unwrap();
1485 let req = fake_request("ltx-video:fp16");
1486 let meta = OutputMetadata::from_generate_request(&req, 1, None, "v");
1487
1488 save_video_to_dir(
1489 tmp.path(),
1490 b"fake gif bytes",
1491 b"",
1492 OutputFormat::Gif,
1493 "ltx-video:fp16",
1494 &meta,
1495 None,
1496 None,
1497 );
1498
1499 let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
1500 assert_eq!(entries.len(), 1);
1501 let name = entries[0]
1502 .as_ref()
1503 .unwrap()
1504 .file_name()
1505 .to_string_lossy()
1506 .to_string();
1507 assert!(name.ends_with(".gif"), "{name}");
1508 }
1509
1510 #[test]
1511 fn save_video_to_dir_invalid_path_does_not_panic() {
1512 let req = fake_request("ltx-video:fp16");
1513 let meta = OutputMetadata::from_generate_request(&req, 1, None, "v");
1514 save_video_to_dir(
1515 std::path::Path::new("/dev/null/nope"),
1516 b"x",
1517 b"",
1518 OutputFormat::Mp4,
1519 "test",
1520 &meta,
1521 None,
1522 None,
1523 );
1524 }
1525
1526 #[test]
1533 fn save_video_preview_gif_writes_to_preview_cache() {
1534 let td = tempfile::tempdir().unwrap();
1535 let preview_dir = td.path().join("cache").join("previews");
1536
1537 const GIF: &[u8] = b"GIF89a\x01\x00\x01\x00\x00\x00\x00\x3b";
1538 save_video_preview_gif_to(&preview_dir, "ltx2-42.mp4", GIF);
1539
1540 let expected = preview_dir.join("ltx2-42.mp4.preview.gif");
1541 assert!(
1542 expected.is_file(),
1543 "preview gif should land at {}",
1544 expected.display()
1545 );
1546 assert_eq!(std::fs::read(&expected).unwrap(), GIF);
1547 }
1548
1549 #[test]
1550 fn build_sse_complete_event_video_carries_mp4_payload_and_metadata() {
1551 let video = mold_core::VideoData {
1558 data: vec![0x00, 0x00, 0x00, 0x18, b'f', b't', b'y', b'p'],
1559 format: OutputFormat::Mp4,
1560 width: 768,
1561 height: 512,
1562 frames: 25,
1563 fps: 24,
1564 thumbnail: vec![0x89, 0x50, 0x4E, 0x47],
1565 gif_preview: vec![b'G', b'I', b'F', b'8'],
1566 has_audio: true,
1567 duration_ms: Some(1040),
1568 audio_sample_rate: Some(44100),
1569 audio_channels: Some(2),
1570 };
1571 let resp = mold_core::GenerateResponse {
1572 images: vec![],
1573 video: Some(video.clone()),
1574 generation_time_ms: 1234,
1575 model: "ltx-2-19b-distilled:fp8".to_string(),
1576 seed_used: 7,
1577 gpu: Some(0),
1578 };
1579 let thumb_img = ImageData {
1582 data: video.thumbnail.clone(),
1583 format: OutputFormat::Png,
1584 width: video.width,
1585 height: video.height,
1586 index: 0,
1587 };
1588
1589 let event = build_sse_complete_event(&resp, &thumb_img);
1590
1591 let b64 = base64::engine::general_purpose::STANDARD;
1592 assert_eq!(event.image, b64.encode(&video.data));
1593 assert_eq!(event.format, OutputFormat::Mp4);
1594 assert_eq!(event.video_frames, Some(25));
1595 assert_eq!(event.video_fps, Some(24));
1596 assert_eq!(event.video_thumbnail, Some(b64.encode(&video.thumbnail)));
1597 assert_eq!(
1598 event.video_gif_preview,
1599 Some(b64.encode(&video.gif_preview))
1600 );
1601 assert!(event.video_has_audio);
1602 assert_eq!(event.video_duration_ms, Some(1040));
1603 assert_eq!(event.gpu, Some(0));
1604 }
1605
1606 #[test]
1607 fn build_sse_complete_event_video_empty_gif_preview_omits_field() {
1608 let video = mold_core::VideoData {
1609 data: vec![0x00, 0x00, 0x00, 0x18],
1610 format: OutputFormat::Mp4,
1611 width: 256,
1612 height: 256,
1613 frames: 17,
1614 fps: 12,
1615 thumbnail: vec![0x89, 0x50],
1616 gif_preview: Vec::new(),
1617 has_audio: false,
1618 duration_ms: None,
1619 audio_sample_rate: None,
1620 audio_channels: None,
1621 };
1622 let resp = mold_core::GenerateResponse {
1623 images: vec![],
1624 video: Some(video),
1625 generation_time_ms: 0,
1626 model: "m".to_string(),
1627 seed_used: 0,
1628 gpu: None,
1629 };
1630 let event = build_sse_complete_event(&resp, &fake_image());
1631 assert!(event.video_gif_preview.is_none());
1632 assert!(!event.video_has_audio);
1633 }
1634
1635 #[test]
1636 fn build_sse_complete_event_image_clears_all_video_fields() {
1637 let resp = mold_core::GenerateResponse {
1638 images: vec![fake_image()],
1639 video: None,
1640 generation_time_ms: 100,
1641 model: "flux-schnell:q8".to_string(),
1642 seed_used: 5,
1643 gpu: None,
1644 };
1645 let event = build_sse_complete_event(&resp, &fake_image());
1646 assert_eq!(event.format, OutputFormat::Png);
1647 assert!(event.video_frames.is_none());
1648 assert!(event.video_fps.is_none());
1649 assert!(event.video_thumbnail.is_none());
1650 assert!(event.video_gif_preview.is_none());
1651 assert!(!event.video_has_audio);
1652 assert!(event.video_duration_ms.is_none());
1653 }
1654
1655 #[test]
1656 fn post_generation_upscale_replaces_image_response_dimensions() {
1657 let mut req = fake_request("flux-dev:q4");
1658 req.upscale_model = Some("real-esrgan-x4plus:fp16".to_string());
1659 let mut response = mold_core::GenerateResponse {
1660 images: vec![],
1661 video: None,
1662 generation_time_ms: 100,
1663 model: "flux-dev:q4".to_string(),
1664 seed_used: 5,
1665 gpu: None,
1666 };
1667 let img = fake_image();
1668 let upscaled = mold_core::UpscaleResponse {
1669 image: ImageData {
1670 data: vec![1, 2, 3],
1671 format: OutputFormat::Png,
1672 width: 2048,
1673 height: 2048,
1674 index: 0,
1675 },
1676 upscale_time_ms: 42,
1677 model: "real-esrgan-x4plus:fp16".to_string(),
1678 scale_factor: 4,
1679 original_width: 512,
1680 original_height: 512,
1681 };
1682
1683 let next = apply_upscale_response_to_image_generation(&req, &mut response, img, upscaled)
1684 .expect("image upscale should apply");
1685 let event = build_sse_complete_event(&response, &next);
1686 let mut metadata =
1687 OutputMetadata::from_generate_request(&req, response.seed_used, None, "test-version");
1688 apply_output_dimensions_to_metadata(&mut metadata, &next);
1689
1690 assert_eq!(next.width, 2048);
1691 assert_eq!(next.height, 2048);
1692 assert_eq!(event.width, 2048);
1693 assert_eq!(event.height, 2048);
1694 assert_eq!(metadata.width, 2048);
1695 assert_eq!(metadata.height, 2048);
1696 assert_eq!(
1697 metadata.upscale_model.as_deref(),
1698 Some("real-esrgan-x4plus:fp16")
1699 );
1700 }
1701
1702 #[test]
1703 fn post_generation_upscale_skips_video_responses() {
1704 let mut req = fake_request("ltx-video:fp16");
1705 req.upscale_model = Some("real-esrgan-x4plus:fp16".to_string());
1706 let video = mold_core::VideoData {
1707 data: vec![0, 0, 0, 24],
1708 format: OutputFormat::Mp4,
1709 width: 512,
1710 height: 512,
1711 frames: 25,
1712 fps: 24,
1713 thumbnail: vec![9, 9],
1714 gif_preview: vec![],
1715 has_audio: false,
1716 duration_ms: None,
1717 audio_sample_rate: None,
1718 audio_channels: None,
1719 };
1720 let mut response = mold_core::GenerateResponse {
1721 images: vec![],
1722 video: Some(video),
1723 generation_time_ms: 100,
1724 model: "ltx-video:fp16".to_string(),
1725 seed_used: 5,
1726 gpu: None,
1727 };
1728 let img = fake_image();
1729 let upscaled = mold_core::UpscaleResponse {
1730 image: ImageData {
1731 data: vec![1, 2, 3],
1732 format: OutputFormat::Png,
1733 width: 2048,
1734 height: 2048,
1735 index: 0,
1736 },
1737 upscale_time_ms: 42,
1738 model: "real-esrgan-x4plus:fp16".to_string(),
1739 scale_factor: 4,
1740 original_width: 512,
1741 original_height: 512,
1742 };
1743
1744 let next = apply_upscale_response_to_image_generation(&req, &mut response, img, upscaled)
1745 .expect("video upscale should be skipped");
1746
1747 assert_eq!(next.width, 512);
1748 assert_eq!(next.height, 512);
1749 assert!(response.video.is_some());
1750 }
1751
1752 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1753 async fn single_worker_post_upscale_noops_without_model() {
1754 let state = empty_test_state(mold_core::Config::default());
1755 let req = fake_request("flux-dev:q4");
1756
1757 let next = upscale_generated_image_on_single_worker(&state, &req, fake_image(), None)
1758 .await
1759 .expect("missing upscale model should leave the image unchanged");
1760
1761 assert_eq!(next.width, 512);
1762 assert_eq!(next.height, 512);
1763 assert_eq!(next.index, 0);
1764 }
1765
1766 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1767 async fn single_worker_post_upscale_rejects_unknown_upscaler_manifest() {
1768 let state = empty_test_state(mold_core::Config::default());
1769 let mut req = fake_request("flux-dev:q4");
1770 req.upscale_model = Some("definitely-not-a-real-upscaler:fp16".to_string());
1771 let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
1772
1773 let err = upscale_generated_image_on_single_worker(
1774 &state,
1775 &req,
1776 fake_image(),
1777 Some(&progress_tx),
1778 )
1779 .await
1780 .expect_err("unknown upscalers should fail before generation completes");
1781
1782 assert!(err.contains("unknown upscaler model"), "got: {err}");
1783 let first_progress = progress_rx
1784 .try_recv()
1785 .expect("loading stage should be emitted before validation fails");
1786 assert!(matches!(
1787 first_progress,
1788 SseMessage::Progress(SseProgressEvent::StageStart { .. })
1789 ));
1790 }
1791
1792 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1793 async fn single_worker_post_upscale_surfaces_missing_weights_path() {
1794 let tmp = TempDir::new().unwrap();
1795 let missing_weights = tmp.path().join("missing-upscaler.safetensors");
1796 let mut config = mold_core::Config::default();
1797 config.models.insert(
1798 "real-esrgan-x4plus:fp16".to_string(),
1799 ModelConfig {
1800 transformer: Some(missing_weights.display().to_string()),
1801 ..Default::default()
1802 },
1803 );
1804 let state = empty_test_state(config);
1805 let mut req = fake_request("flux-dev:q4");
1806 req.upscale_model = Some("real-esrgan-x4plus:fp16".to_string());
1807 let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
1808
1809 let err = upscale_generated_image_on_single_worker(
1810 &state,
1811 &req,
1812 fake_image(),
1813 Some(&progress_tx),
1814 )
1815 .await
1816 .expect_err("missing weight files should be surfaced");
1817
1818 assert!(err.contains("upscale failed"), "got: {err}");
1819 assert!(err.contains("upscaler weights not found"), "got: {err}");
1820 let first_progress = progress_rx
1821 .try_recv()
1822 .expect("loading stage should be emitted before loading fails");
1823 assert!(matches!(
1824 first_progress,
1825 SseMessage::Progress(SseProgressEvent::StageStart { .. })
1826 ));
1827 }
1828
1829 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1830 async fn queue_dispatcher_waits_for_worker_capacity_instead_of_rejecting() {
1831 let (worker, worker_rx) = test_worker(0, 1);
1832 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
1833 let queue = QueueHandle::new(job_tx.clone());
1834 let state = crate::state::AppState::empty(
1835 mold_core::Config::default(),
1836 queue.clone(),
1837 Arc::new(GpuPool {
1838 workers: vec![worker.clone()],
1839 }),
1840 8,
1841 );
1842
1843 let (filler_result_tx, _filler_result_rx) = tokio::sync::oneshot::channel();
1844 let filler_job = crate::gpu_pool::GpuJob {
1845 id: String::new(),
1846 model: "busy-model".to_string(),
1847 request: fake_request("busy-model"),
1848 progress_tx: None,
1849 result_tx: filler_result_tx,
1850 output_dir: None,
1851 config: state.config.clone(),
1852 metadata_db: state.metadata_db.clone(),
1853 queue: state.queue.clone(),
1854 registry: state.job_registry.clone(),
1855 };
1856 worker.job_tx.send(filler_job).unwrap();
1857
1858 let dispatcher = tokio::spawn(run_queue_dispatcher_with_tuning(
1859 job_rx,
1860 state.clone(),
1861 8,
1862 DEFAULT_MAX_DEFERRALS,
1863 ));
1864
1865 let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
1866 let job = crate::state::GenerationJob {
1867 id: String::new(),
1868 request: fake_request("flux-dev:q4"),
1869 progress_tx: None,
1870 result_tx,
1871 output_dir: None,
1872 };
1873 let _position = queue.submit(job, 8).await.unwrap();
1874
1875 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1876 assert!(
1877 result_rx.try_recv().is_err(),
1878 "dispatcher should keep the job pending while all worker channels are full"
1879 );
1880
1881 let _filler = worker_rx
1882 .recv()
1883 .expect("filler job should occupy the local channel");
1884 let dispatched = worker_rx
1885 .recv_timeout(std::time::Duration::from_secs(1))
1886 .expect("queued job should dispatch once capacity is available");
1887 assert_eq!(dispatched.model, "flux-dev:q4");
1888
1889 drop(job_tx);
1890 dispatcher.abort();
1891 }
1892
1893 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1894 async fn queue_dispatcher_waits_for_degraded_worker_recovery_instead_of_rejecting() {
1895 let (worker, worker_rx) = test_worker(0, 1);
1896 worker.consecutive_failures.store(3, Ordering::SeqCst);
1897 *worker.degraded_until.write().unwrap() =
1898 Some(Instant::now() + std::time::Duration::from_secs(60));
1899
1900 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
1901 let queue = QueueHandle::new(job_tx.clone());
1902 let state = crate::state::AppState::empty(
1903 mold_core::Config::default(),
1904 queue.clone(),
1905 Arc::new(GpuPool {
1906 workers: vec![worker.clone()],
1907 }),
1908 8,
1909 );
1910 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
1911
1912 let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
1913 let job = crate::state::GenerationJob {
1914 id: String::new(),
1915 request: fake_request("flux-dev:q4"),
1916 progress_tx: None,
1917 result_tx,
1918 output_dir: None,
1919 };
1920 queue.submit(job, 8).await.unwrap();
1921
1922 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1923 assert!(
1924 result_rx.try_recv().is_err(),
1925 "dispatcher should keep the job pending while all workers are degraded"
1926 );
1927 assert!(
1928 worker_rx.try_recv().is_err(),
1929 "degraded worker must not receive work before recovery"
1930 );
1931
1932 worker.consecutive_failures.store(0, Ordering::SeqCst);
1933 *worker.degraded_until.write().unwrap() = None;
1934
1935 let dispatched = worker_rx
1936 .recv_timeout(std::time::Duration::from_secs(1))
1937 .expect("queued job should dispatch once a worker recovers");
1938 assert_eq!(dispatched.model, "flux-dev:q4");
1939
1940 drop(job_tx);
1941 dispatcher.abort();
1942 }
1943
1944 #[tokio::test]
1951 async fn cache_take_on_vanished_engine_returns_none_not_panic() {
1952 use crate::model_cache::ModelCache;
1953 use mold_core::GenerateResponse;
1954 use mold_inference::InferenceEngine;
1955
1956 struct StubEngine(&'static str);
1957 impl InferenceEngine for StubEngine {
1958 fn generate(&mut self, _r: &GenerateRequest) -> anyhow::Result<GenerateResponse> {
1959 unimplemented!()
1960 }
1961 fn model_name(&self) -> &str {
1962 self.0
1963 }
1964 fn is_loaded(&self) -> bool {
1965 true
1966 }
1967 fn load(&mut self) -> anyhow::Result<()> {
1968 Ok(())
1969 }
1970 }
1971
1972 let mut cache = ModelCache::new(3);
1973 assert!(cache.take("vanished-model").is_none());
1976
1977 cache.insert(Box::new(StubEngine("present-model")), 0);
1981 let first = cache.take("present-model");
1982 assert!(first.is_some());
1983 assert!(
1984 cache.take("present-model").is_none(),
1985 "double-take must return None"
1986 );
1987 }
1988
1989 fn buf_job(model: &str) -> BufferedJob {
1990 let (tx, _rx) = tokio::sync::oneshot::channel();
1991 BufferedJob::new(crate::state::GenerationJob {
1992 id: String::new(),
1993 request: fake_request(model),
1994 progress_tx: None,
1995 result_tx: tx,
1996 output_dir: None,
1997 })
1998 }
1999
2000 #[test]
2001 fn pick_next_job_picks_head_when_head_model_loaded() {
2002 use std::collections::{HashSet, VecDeque};
2003 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2004 buffer.push_back(buf_job("a"));
2005 buffer.push_back(buf_job("b"));
2006 buffer.push_back(buf_job("a"));
2007 let loaded: HashSet<String> = ["a".to_string()].into_iter().collect();
2008 let picked = pick_next_job(&mut buffer, &loaded, 3);
2009 assert_eq!(picked.request.model, "a");
2010 assert_eq!(buffer.len(), 2);
2011 assert_eq!(buffer.front().unwrap().job.request.model, "b");
2012 assert_eq!(
2013 buffer.front().unwrap().deferred,
2014 0,
2015 "head shouldn't be deferred when picker chose the head itself"
2016 );
2017 }
2018
2019 #[test]
2020 fn pick_next_job_picks_non_head_when_only_non_head_model_loaded() {
2021 use std::collections::{HashSet, VecDeque};
2022 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2023 buffer.push_back(buf_job("a"));
2024 buffer.push_back(buf_job("b"));
2025 buffer.push_back(buf_job("a"));
2026 let loaded: HashSet<String> = ["b".to_string()].into_iter().collect();
2027 let picked = pick_next_job(&mut buffer, &loaded, 3);
2028 assert_eq!(picked.request.model, "b");
2029 assert_eq!(buffer.len(), 2);
2030 assert_eq!(buffer.front().unwrap().job.request.model, "a");
2032 assert_eq!(buffer.front().unwrap().deferred, 1);
2033 }
2034
2035 #[test]
2036 fn pick_next_job_force_dispatches_head_after_max_deferrals() {
2037 use std::collections::{HashSet, VecDeque};
2038 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2039 let mut head = buf_job("a");
2040 head.deferred = 3;
2041 buffer.push_back(head);
2042 buffer.push_back(buf_job("b"));
2043 let loaded: HashSet<String> = ["b".to_string()].into_iter().collect();
2045 let picked = pick_next_job(&mut buffer, &loaded, 3);
2046 assert_eq!(picked.request.model, "a");
2047 assert_eq!(buffer.len(), 1);
2048 assert_eq!(buffer.front().unwrap().job.request.model, "b");
2049 }
2050
2051 #[test]
2052 fn pick_next_job_falls_back_to_head_when_nothing_loaded() {
2053 use std::collections::{HashSet, VecDeque};
2054 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2055 buffer.push_back(buf_job("a"));
2056 buffer.push_back(buf_job("b"));
2057 let loaded: HashSet<String> = HashSet::new();
2058 let picked = pick_next_job(&mut buffer, &loaded, 3);
2059 assert_eq!(picked.request.model, "a");
2060 }
2061
2062 #[test]
2066 fn pick_next_job_max_deferrals_zero_picks_head_even_when_non_head_loaded() {
2067 use std::collections::{HashSet, VecDeque};
2068 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2069 buffer.push_back(buf_job("b")); buffer.push_back(buf_job("a")); let loaded: HashSet<String> = ["a".to_string()].into_iter().collect();
2072 let picked = pick_next_job(&mut buffer, &loaded, 0);
2073 assert_eq!(
2074 picked.request.model, "b",
2075 "max_deferrals=0 must force FIFO — head must win even when only the non-head model is loaded"
2076 );
2077 assert_eq!(buffer.len(), 1);
2078 assert_eq!(buffer.front().unwrap().job.request.model, "a");
2079 }
2080
2081 #[test]
2085 fn pick_next_job_max_deferrals_zero_with_empty_loaded_picks_head() {
2086 use std::collections::{HashSet, VecDeque};
2087 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2088 buffer.push_back(buf_job("a")); buffer.push_back(buf_job("b"));
2090 let loaded: HashSet<String> = HashSet::new();
2091 let picked = pick_next_job(&mut buffer, &loaded, 0);
2092 assert_eq!(picked.request.model, "a");
2093 assert_eq!(buffer.len(), 1);
2094 assert_eq!(buffer.front().unwrap().job.request.model, "b");
2095 }
2096
2097 #[test]
2102 fn pick_next_job_picks_front_most_match_when_multiple_loaded() {
2103 use std::collections::{HashSet, VecDeque};
2104 let mut buffer: VecDeque<BufferedJob> = VecDeque::new();
2105 buffer.push_back(buf_job("a"));
2106 buffer.push_back(buf_job("b"));
2107 buffer.push_back(buf_job("a"));
2108 buffer.push_back(buf_job("b"));
2109 let loaded: HashSet<String> = ["a".to_string(), "b".to_string()].into_iter().collect();
2110 let picked = pick_next_job(&mut buffer, &loaded, 3);
2111 assert_eq!(
2112 picked.request.model, "a",
2113 "front-most match wins (the first `a`), not the loaded model with the most copies later in the buffer"
2114 );
2115 assert_eq!(buffer.len(), 3);
2119 let remaining: Vec<&str> = buffer
2120 .iter()
2121 .map(|b| b.job.request.model.as_str())
2122 .collect();
2123 assert_eq!(remaining, vec!["b", "a", "b"]);
2124 assert_eq!(buffer.front().unwrap().deferred, 0);
2125 }
2126
2127 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2131 async fn queue_dispatcher_reorders_interleaved_jobs_to_minimize_swaps() {
2132 let (worker, worker_rx) = test_worker(0, 8);
2133 {
2136 let mut cache = worker.model_cache.lock().unwrap();
2137 struct Engine(&'static str);
2138 impl mold_inference::InferenceEngine for Engine {
2139 fn generate(
2140 &mut self,
2141 _r: &GenerateRequest,
2142 ) -> anyhow::Result<mold_core::GenerateResponse> {
2143 unimplemented!()
2144 }
2145 fn model_name(&self) -> &str {
2146 self.0
2147 }
2148 fn is_loaded(&self) -> bool {
2149 true
2150 }
2151 fn load(&mut self) -> anyhow::Result<()> {
2152 Ok(())
2153 }
2154 }
2155 cache.insert(Box::new(Engine("a")), 0);
2156 }
2157
2158 let (job_tx, job_rx) = tokio::sync::mpsc::channel(8);
2159 let queue = QueueHandle::new(job_tx.clone());
2160 let state = crate::state::AppState::empty(
2161 mold_core::Config::default(),
2162 queue.clone(),
2163 Arc::new(GpuPool {
2164 workers: vec![worker.clone()],
2165 }),
2166 8,
2167 );
2168
2169 let mut result_rxs = Vec::new();
2172 for model in ["a", "b", "a", "b"] {
2173 let (tx, rx) = tokio::sync::oneshot::channel();
2174 let job = crate::state::GenerationJob {
2175 id: String::new(),
2176 request: fake_request(model),
2177 progress_tx: None,
2178 result_tx: tx,
2179 output_dir: None,
2180 };
2181 queue.submit(job, 8).await.unwrap();
2182 result_rxs.push(rx);
2183 }
2184
2185 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2186
2187 let mut order = Vec::new();
2188 for _ in 0..4 {
2189 let dispatched = worker_rx
2190 .recv_timeout(std::time::Duration::from_secs(2))
2191 .expect("worker should receive the dispatched job");
2192 order.push(dispatched.model);
2193 }
2194 drop(job_tx);
2195 dispatcher.abort();
2196
2197 assert_eq!(
2198 order,
2199 vec![
2200 "a".to_string(),
2201 "a".to_string(),
2202 "b".to_string(),
2203 "b".to_string(),
2204 ],
2205 "lookahead reorder should batch all `a` jobs together before swapping to `b`"
2206 );
2207 }
2208
2209 #[tokio::test]
2216 async fn top_up_buffer_never_exceeds_capacity() {
2217 use std::collections::VecDeque;
2218 let (job_tx, mut job_rx) = tokio::sync::mpsc::channel::<GenerationJob>(32);
2219
2220 for i in 0..10 {
2223 let (tx, _rx) = tokio::sync::oneshot::channel();
2224 let job = GenerationJob {
2225 id: String::new(),
2226 request: fake_request(&format!("model-{i}")),
2227 progress_tx: None,
2228 result_tx: tx,
2229 output_dir: None,
2230 };
2231 job_tx.send(job).await.unwrap();
2232 }
2233
2234 let mut buffer: VecDeque<BufferedJob> = VecDeque::with_capacity(4);
2236 top_up_buffer(&mut buffer, &mut job_rx, 4);
2237 assert_eq!(
2238 buffer.len(),
2239 4,
2240 "top_up_buffer must cap at buffer_size, leaving the rest in the channel"
2241 );
2242
2243 while buffer.pop_front().is_some() {}
2246 top_up_buffer(&mut buffer, &mut job_rx, 4);
2247 assert_eq!(buffer.len(), 4);
2248 let names: Vec<&str> = buffer
2249 .iter()
2250 .map(|b| b.job.request.model.as_str())
2251 .collect();
2252 assert_eq!(
2253 names,
2254 vec!["model-4", "model-5", "model-6", "model-7"],
2255 "second top-up must drain the next FIFO window from the channel"
2256 );
2257
2258 drop(job_tx);
2261 while buffer.pop_front().is_some() {}
2262 top_up_buffer(&mut buffer, &mut job_rx, 4);
2263 assert_eq!(
2264 buffer.len(),
2265 2,
2266 "top_up_buffer drains the channel tail when fewer jobs than capacity remain"
2267 );
2268 let names: Vec<&str> = buffer
2269 .iter()
2270 .map(|b| b.job.request.model.as_str())
2271 .collect();
2272 assert_eq!(names, vec!["model-8", "model-9"]);
2273 }
2274
2275 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2281 async fn queue_dispatcher_dispatches_all_jobs_when_submission_exceeds_buffer() {
2282 let (worker, worker_rx) = test_worker(0, 4);
2283 let (job_tx, job_rx) = tokio::sync::mpsc::channel(32);
2284 let queue = QueueHandle::new(job_tx.clone());
2285 let state = crate::state::AppState::empty(
2286 mold_core::Config::default(),
2287 queue.clone(),
2288 Arc::new(GpuPool {
2289 workers: vec![worker.clone()],
2290 }),
2291 32,
2292 );
2293
2294 let drain_worker = worker.clone();
2300 let drainer = std::thread::spawn(move || {
2301 let mut order = Vec::new();
2302 while order.len() < 10 {
2303 match worker_rx.recv_timeout(std::time::Duration::from_secs(5)) {
2304 Ok(j) => {
2305 drain_worker.in_flight.fetch_sub(1, Ordering::SeqCst);
2306 order.push(j.model);
2307 }
2308 Err(e) => panic!("drain stalled at {:?}: {e:?}", order),
2309 }
2310 }
2311 order
2312 });
2313
2314 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2315
2316 let mut held_rxs = Vec::new();
2322 for i in 0..10 {
2323 let (tx, rx) = tokio::sync::oneshot::channel();
2324 held_rxs.push(rx);
2325 let job = crate::state::GenerationJob {
2326 id: String::new(),
2327 request: fake_request(&format!("model-{i}")),
2328 progress_tx: None,
2329 result_tx: tx,
2330 output_dir: None,
2331 };
2332 queue.submit(job, 32).await.unwrap();
2333 }
2334
2335 let order = drainer.join().expect("drainer thread panic");
2336 drop(job_tx);
2337 dispatcher.abort();
2338
2339 let expected: Vec<String> = (0..10).map(|i| format!("model-{i}")).collect();
2340 assert_eq!(
2341 order, expected,
2342 "10 distinct jobs must come out in FIFO across buffer rotations"
2343 );
2344 }
2345
2346 static QUEUE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2348
2349 fn with_queue_env<R>(name: &str, value: Option<&str>, f: impl FnOnce() -> R) -> R {
2350 let _g = QUEUE_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2351 let prev = std::env::var(name).ok();
2352 match value {
2353 Some(v) => std::env::set_var(name, v),
2354 None => std::env::remove_var(name),
2355 }
2356 let out = f();
2357 match prev {
2358 Some(v) => std::env::set_var(name, v),
2359 None => std::env::remove_var(name),
2360 }
2361 out
2362 }
2363
2364 #[test]
2365 fn resolve_lookahead_buffer_uses_default_when_env_missing() {
2366 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, None, resolve_lookahead_buffer);
2367 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2368 }
2369
2370 #[test]
2371 fn resolve_lookahead_buffer_honors_env_within_range() {
2372 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("4"), resolve_lookahead_buffer);
2373 assert_eq!(n, 4);
2374 }
2375
2376 #[test]
2377 fn resolve_lookahead_buffer_falls_back_when_out_of_range() {
2378 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("0"), resolve_lookahead_buffer);
2380 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2381 let n = with_queue_env(LOOKAHEAD_BUFFER_ENV, Some("999"), resolve_lookahead_buffer);
2382 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2383 }
2384
2385 #[test]
2386 fn resolve_lookahead_buffer_falls_back_when_unparseable() {
2387 let n = with_queue_env(
2388 LOOKAHEAD_BUFFER_ENV,
2389 Some("not-a-number"),
2390 resolve_lookahead_buffer,
2391 );
2392 assert_eq!(n, DEFAULT_LOOKAHEAD_BUFFER);
2393 }
2394
2395 #[test]
2396 fn resolve_max_deferrals_uses_default_when_env_missing() {
2397 let n = with_queue_env(MAX_DEFERRALS_ENV, None, resolve_max_deferrals);
2398 assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2399 }
2400
2401 #[test]
2402 fn resolve_max_deferrals_honors_env_within_range() {
2403 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("0"), resolve_max_deferrals);
2405 assert_eq!(n, 0);
2406 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("32"), resolve_max_deferrals);
2407 assert_eq!(n, 32);
2408 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("5"), resolve_max_deferrals);
2409 assert_eq!(n, 5);
2410 }
2411
2412 #[test]
2413 fn resolve_max_deferrals_falls_back_when_out_of_range() {
2414 let n = with_queue_env(MAX_DEFERRALS_ENV, Some("999"), resolve_max_deferrals);
2415 assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2416 }
2417
2418 #[test]
2419 fn resolve_max_deferrals_falls_back_when_unparseable() {
2420 let n = with_queue_env(
2421 MAX_DEFERRALS_ENV,
2422 Some("not-a-number"),
2423 resolve_max_deferrals,
2424 );
2425 assert_eq!(n, DEFAULT_MAX_DEFERRALS);
2426 }
2427
2428 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2429 async fn queue_dispatcher_honors_explicit_placement_gpu() {
2430 let (worker0, rx0) = test_worker(0, 1);
2431 let (worker1, rx1) = test_worker(1, 1);
2432 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2433 let queue = QueueHandle::new(job_tx.clone());
2434 let state = crate::state::AppState::empty(
2435 mold_core::Config::default(),
2436 queue.clone(),
2437 Arc::new(GpuPool {
2438 workers: vec![worker0, worker1],
2439 }),
2440 8,
2441 );
2442
2443 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state));
2444
2445 let mut request = fake_request("flux-dev:q4");
2446 request.placement = Some(mold_core::types::DevicePlacement {
2447 text_encoders: mold_core::types::DeviceRef::Auto,
2448 advanced: Some(mold_core::types::AdvancedPlacement {
2449 transformer: mold_core::types::DeviceRef::gpu(1),
2450 ..mold_core::types::AdvancedPlacement::default()
2451 }),
2452 });
2453
2454 let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
2455 let job = crate::state::GenerationJob {
2456 id: String::new(),
2457 request,
2458 progress_tx: None,
2459 result_tx,
2460 output_dir: None,
2461 };
2462 let _position = queue.submit(job, 8).await.unwrap();
2463
2464 let dispatched = rx1
2465 .recv_timeout(std::time::Duration::from_secs(1))
2466 .expect("explicit placement should route to gpu 1");
2467 assert_eq!(dispatched.model, "flux-dev:q4");
2468 assert!(rx0.try_recv().is_err(), "gpu 0 should not receive the job");
2469
2470 drop(job_tx);
2471 dispatcher.abort();
2472 }
2473
2474 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2475 async fn queue_dispatcher_records_auto_selected_gpu_before_worker_starts() {
2476 let (worker0, rx0) = test_worker(0, 1);
2477 let (worker1, rx1) = test_worker(1, 1);
2478 let (job_tx, job_rx) = tokio::sync::mpsc::channel(4);
2479 let queue = QueueHandle::new(job_tx.clone());
2480 let state = crate::state::AppState::empty(
2481 mold_core::Config::default(),
2482 queue.clone(),
2483 Arc::new(GpuPool {
2484 workers: vec![worker0, worker1],
2485 }),
2486 8,
2487 );
2488 state.job_registry.register("auto-job", "flux-dev:q4");
2489
2490 let dispatcher = tokio::spawn(run_queue_dispatcher(job_rx, state.clone()));
2491
2492 let (result_tx, _result_rx) = tokio::sync::oneshot::channel();
2493 let job = crate::state::GenerationJob {
2494 id: "auto-job".to_string(),
2495 request: fake_request("flux-dev:q4"),
2496 progress_tx: None,
2497 result_tx,
2498 output_dir: None,
2499 };
2500 let _position = queue.submit(job, 8).await.unwrap();
2501
2502 let (dispatched, ordinal) = match rx0.recv_timeout(std::time::Duration::from_secs(1)) {
2503 Ok(job) => (job, 0),
2504 Err(_) => (
2505 rx1.recv_timeout(std::time::Duration::from_secs(1))
2506 .expect("auto job should dispatch to one GPU"),
2507 1,
2508 ),
2509 };
2510 assert_eq!(dispatched.model, "flux-dev:q4");
2511 assert_eq!(
2512 state.job_registry.target_gpu("auto-job"),
2513 Some(Some(ordinal))
2514 );
2515
2516 drop(job_tx);
2517 dispatcher.abort();
2518 }
2519}