1use serde::Serialize;
19use std::sync::{Arc, RwLock};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
50 pub gpu: Option<usize>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub target_gpu: Option<usize>,
54}
55
56#[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
80pub struct JobRegistry {
85 inner: RwLock<Vec<EntryInternal>>,
86}
87
88pub 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 pub fn register(&self, id: impl Into<String>, model: impl Into<String>) {
100 self.register_with_target_gpu(id, model, None);
101 }
102
103 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 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 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 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 pub fn len(&self) -> usize {
204 self.inner.read().unwrap_or_else(|e| e.into_inner()).len()
205 }
206
207 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 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 let reg = JobRegistry::new();
299 reg.register("a", "flux-dev:fp16");
300 reg.remove("a");
301 reg.remove("a"); 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 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}