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