Skip to main content

mold_server/
queue.rs

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