Skip to main content

mold_server/
job_registry.rs

1//! Server-side ledger of in-flight generation jobs.
2//!
3//! The web UI tracks each "Generate" click as a card in `useGenerateStream`
4//! and relies on the SSE stream as the only signal that the work is still
5//! happening. When that stream silently drops (network blip, proxy idle
6//! timeout, server restart, browser tab suspended past keepalive), the card
7//! gets stuck `running` forever because no terminal event arrives.
8//!
9//! `JobRegistry` is the server's authoritative list of "things still owed an
10//! output" — every job between `submit()` and worker completion. The new
11//! `GET /api/queue` endpoint exposes this list so the SPA can poll it and
12//! dead-letter cards whose server-assigned `id` is no longer present.
13//!
14//! The registry deliberately doesn't track *completed* jobs — the gallery DB
15//! is the source of truth for those. Anything in here is currently queued or
16//! actively running on some worker.
17
18use serde::Serialize;
19use std::sync::{Arc, RwLock};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22/// Wire-facing job state. Mirrors the actual lifecycle:
23///
24/// - `queued` — accepted by `submit()`, sitting in the channel awaiting a
25///   dispatcher decision OR the dispatcher is mid-retry across workers.
26/// - `running` — handed off to a GPU worker thread; flipping happens when
27///   the worker pulls the job off its channel and starts loading / inferring.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, utoipa::ToSchema)]
29#[serde(rename_all = "snake_case")]
30pub enum JobLifecycle {
31    Queued,
32    Running,
33}
34
35/// One row in the `GET /api/queue` response.
36///
37/// `position` is the 0-based FIFO index at the time of the snapshot — 0 is at
38/// the head (about to be dispatched, or already running on a worker), N-1 is
39/// the most recently submitted. Position is derived from insertion order, so
40/// it shifts as earlier jobs finish and drop out.
41#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
42pub struct JobEntry {
43    pub id: String,
44    pub model: String,
45    pub state: JobLifecycle,
46    pub started_at_unix_ms: u64,
47    pub position: usize,
48    /// GPU ordinal currently running this job (`null` for queued rows).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub gpu: Option<usize>,
51    /// Preferred GPU ordinal for queued jobs (`None` means Auto).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub target_gpu: Option<usize>,
54}
55
56/// Whole-queue listing returned by `GET /api/queue`. Wrapped in a struct so
57/// the response can grow extra fields (totals, byte counts, etc.) without a
58/// breaking change.
59#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
60pub struct QueueListing {
61    pub entries: Vec<JobEntry>,
62}
63
64#[derive(Debug, Clone)]
65struct EntryInternal {
66    id: String,
67    model: String,
68    state: JobLifecycle,
69    started_at_unix_ms: u64,
70    gpu: Option<usize>,
71    target_gpu: Option<usize>,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum TargetGpuUpdateError {
76    NotFound,
77    AlreadyRunning,
78}
79
80/// The registry itself. Construct via `JobRegistry::new` and share through
81/// `AppState`. All mutation is fire-and-forget — if the inner lock is
82/// poisoned (extremely unlikely in practice) we recover from the inner
83/// state rather than propagating the panic into the dispatcher hot path.
84pub struct JobRegistry {
85    inner: RwLock<Vec<EntryInternal>>,
86}
87
88/// Cheap-cloneable handle. Workers and routes pass this around by value.
89pub type SharedJobRegistry = Arc<JobRegistry>;
90
91impl JobRegistry {
92    pub fn new() -> SharedJobRegistry {
93        Arc::new(Self {
94            inner: RwLock::new(Vec::new()),
95        })
96    }
97
98    /// Insert a freshly-submitted job at the tail in `Queued` state.
99    pub fn register(&self, id: impl Into<String>, model: impl Into<String>) {
100        self.register_with_target_gpu(id, model, None);
101    }
102
103    /// Insert a freshly-submitted job with an optional queued lane target.
104    pub fn register_with_target_gpu(
105        &self,
106        id: impl Into<String>,
107        model: impl Into<String>,
108        target_gpu: Option<usize>,
109    ) {
110        let started_at_unix_ms = SystemTime::now()
111            .duration_since(UNIX_EPOCH)
112            .unwrap_or_default()
113            .as_millis() as u64;
114        let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
115        entries.push(EntryInternal {
116            id: id.into(),
117            model: model.into(),
118            state: JobLifecycle::Queued,
119            started_at_unix_ms,
120            gpu: None,
121            target_gpu,
122        });
123    }
124
125    /// Promote a registry entry from `Queued` to `Running`. No-op if `id`
126    /// isn't present (the entry may have been removed concurrently).
127    pub fn mark_running(&self, id: &str, gpu: Option<usize>) {
128        let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
129        if let Some(e) = entries.iter_mut().find(|e| e.id == id) {
130            e.state = JobLifecycle::Running;
131            e.gpu = gpu;
132            e.target_gpu = None;
133        }
134    }
135
136    pub fn set_target_gpu(
137        &self,
138        id: &str,
139        target_gpu: Option<usize>,
140    ) -> Result<(), TargetGpuUpdateError> {
141        let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
142        let Some(e) = entries.iter_mut().find(|e| e.id == id) else {
143            return Err(TargetGpuUpdateError::NotFound);
144        };
145        if e.state == JobLifecycle::Running {
146            return Err(TargetGpuUpdateError::AlreadyRunning);
147        }
148        e.target_gpu = target_gpu;
149        Ok(())
150    }
151
152    pub fn target_gpu(&self, id: &str) -> Option<Option<usize>> {
153        let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
154        entries.iter().find(|e| e.id == id).map(|e| e.target_gpu)
155    }
156
157    pub fn entry(&self, id: &str) -> Option<JobEntry> {
158        let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
159        entries.iter().enumerate().find_map(|(i, e)| {
160            (e.id == id).then(|| JobEntry {
161                id: e.id.clone(),
162                model: e.model.clone(),
163                state: e.state,
164                started_at_unix_ms: e.started_at_unix_ms,
165                position: i,
166                gpu: e.gpu,
167                target_gpu: e.target_gpu,
168            })
169        })
170    }
171
172    /// Drop the entry — call once on every terminal path (success, error,
173    /// client-disconnect skip, dispatch failure). Idempotent.
174    pub fn remove(&self, id: &str) {
175        if id.is_empty() {
176            return;
177        }
178        let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
179        entries.retain(|e| e.id != id);
180    }
181
182    /// Snapshot the registry as a wire-shaped listing. Positions are assigned
183    /// in insertion order at snapshot time.
184    pub fn snapshot(&self) -> QueueListing {
185        let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
186        let out = entries
187            .iter()
188            .enumerate()
189            .map(|(i, e)| JobEntry {
190                id: e.id.clone(),
191                model: e.model.clone(),
192                state: e.state,
193                started_at_unix_ms: e.started_at_unix_ms,
194                position: i,
195                gpu: e.gpu,
196                target_gpu: e.target_gpu,
197            })
198            .collect();
199        QueueListing { entries: out }
200    }
201
202    /// Currently-tracked job count. Exposed for tests and metrics.
203    pub fn len(&self) -> usize {
204        self.inner.read().unwrap_or_else(|e| e.into_inner()).len()
205    }
206
207    /// Returns true when nothing is queued or running. Public so other
208    /// callers (metrics, integration tests) can check emptiness without
209    /// allocating a full snapshot.
210    pub fn is_empty(&self) -> bool {
211        self.len() == 0
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn register_appends_in_fifo_order_with_queued_state() {
221        let reg = JobRegistry::new();
222        reg.register("a", "flux-dev:fp16");
223        reg.register("b", "sdxl:q8");
224        let snap = reg.snapshot();
225        assert_eq!(snap.entries.len(), 2);
226        assert_eq!(snap.entries[0].id, "a");
227        assert_eq!(snap.entries[0].position, 0);
228        assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
229        assert_eq!(snap.entries[1].id, "b");
230        assert_eq!(snap.entries[1].position, 1);
231    }
232
233    #[test]
234    fn mark_running_flips_state_and_records_gpu_ordinal() {
235        let reg = JobRegistry::new();
236        reg.register("a", "flux-dev:fp16");
237        reg.mark_running("a", Some(1));
238        let snap = reg.snapshot();
239        assert_eq!(snap.entries[0].state, JobLifecycle::Running);
240        assert_eq!(snap.entries[0].gpu, Some(1));
241    }
242
243    #[test]
244    fn queued_entries_can_carry_target_gpu_metadata() {
245        let reg = JobRegistry::new();
246        reg.register_with_target_gpu("a", "flux-dev:fp16", Some(1));
247        let snap = reg.snapshot();
248        assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
249        assert_eq!(snap.entries[0].target_gpu, Some(1));
250        assert_eq!(snap.entries[0].gpu, None);
251    }
252
253    #[test]
254    fn target_gpu_updates_only_apply_to_queued_entries() {
255        let reg = JobRegistry::new();
256        reg.register("a", "flux-dev:fp16");
257        reg.set_target_gpu("a", Some(1)).unwrap();
258        assert_eq!(reg.target_gpu("a"), Some(Some(1)));
259
260        reg.mark_running("a", Some(1));
261        let err = reg.set_target_gpu("a", None).unwrap_err();
262        assert_eq!(err, TargetGpuUpdateError::AlreadyRunning);
263        assert_eq!(reg.target_gpu("a"), Some(None));
264    }
265
266    #[test]
267    fn mark_running_is_a_noop_for_unknown_ids() {
268        let reg = JobRegistry::new();
269        reg.register("a", "flux-dev:fp16");
270        // No panic, no insertion — bogus id is ignored entirely.
271        reg.mark_running("not-here", Some(0));
272        let snap = reg.snapshot();
273        assert_eq!(snap.entries.len(), 1);
274        assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
275    }
276
277    #[test]
278    fn remove_compacts_positions_for_the_survivors() {
279        let reg = JobRegistry::new();
280        reg.register("a", "flux-dev:fp16");
281        reg.register("b", "sdxl:q8");
282        reg.register("c", "ltx-video:q8");
283        reg.remove("b");
284        let snap = reg.snapshot();
285        assert_eq!(snap.entries.len(), 2);
286        assert_eq!(snap.entries[0].id, "a");
287        assert_eq!(snap.entries[0].position, 0);
288        assert_eq!(snap.entries[1].id, "c");
289        assert_eq!(snap.entries[1].position, 1);
290    }
291
292    #[test]
293    fn remove_is_idempotent_and_ignores_empty_ids() {
294        // The worker's QueueSlot drop guard removes unconditionally — if the
295        // dispatcher already removed the entry on an error path, the worker's
296        // remove must not panic. Same for jobs that bypassed the registry
297        // entirely (id == "").
298        let reg = JobRegistry::new();
299        reg.register("a", "flux-dev:fp16");
300        reg.remove("a");
301        reg.remove("a"); // second remove is a no-op
302        reg.remove("");
303        reg.remove("never-existed");
304        assert!(reg.is_empty());
305    }
306
307    #[test]
308    fn snapshot_serializes_with_snake_case_state_and_omits_gpu_when_queued() {
309        // Wire contract: queued rows must NOT carry a `gpu` field at all
310        // (clients shouldn't see `"gpu": null` and infer GPU 0). The state
311        // tag is lowercase to match the rest of the SSE/JSON style.
312        let reg = JobRegistry::new();
313        reg.register("a", "flux-dev:fp16");
314        let snap = reg.snapshot();
315        let json = serde_json::to_string(&snap.entries[0]).unwrap();
316        assert!(json.contains(r#""state":"queued""#), "got: {json}");
317        assert!(
318            !json.contains("gpu"),
319            "queued row leaked a gpu field: {json}"
320        );
321
322        reg.mark_running("a", Some(0));
323        let snap2 = reg.snapshot();
324        let json2 = serde_json::to_string(&snap2.entries[0]).unwrap();
325        assert!(json2.contains(r#""state":"running""#));
326        assert!(json2.contains(r#""gpu":0"#));
327    }
328}