use serde::Serialize;
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum JobLifecycle {
Queued,
Running,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct JobEntry {
pub id: String,
pub model: String,
pub state: JobLifecycle,
pub started_at_unix_ms: u64,
pub position: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub gpu: Option<usize>,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct QueueListing {
pub entries: Vec<JobEntry>,
}
#[derive(Debug, Clone)]
struct EntryInternal {
id: String,
model: String,
state: JobLifecycle,
started_at_unix_ms: u64,
gpu: Option<usize>,
}
pub struct JobRegistry {
inner: RwLock<Vec<EntryInternal>>,
}
pub type SharedJobRegistry = Arc<JobRegistry>;
impl JobRegistry {
pub fn new() -> SharedJobRegistry {
Arc::new(Self {
inner: RwLock::new(Vec::new()),
})
}
pub fn register(&self, id: impl Into<String>, model: impl Into<String>) {
let started_at_unix_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
entries.push(EntryInternal {
id: id.into(),
model: model.into(),
state: JobLifecycle::Queued,
started_at_unix_ms,
gpu: None,
});
}
pub fn mark_running(&self, id: &str, gpu: Option<usize>) {
let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
if let Some(e) = entries.iter_mut().find(|e| e.id == id) {
e.state = JobLifecycle::Running;
e.gpu = gpu;
}
}
pub fn remove(&self, id: &str) {
if id.is_empty() {
return;
}
let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
entries.retain(|e| e.id != id);
}
pub fn snapshot(&self) -> QueueListing {
let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
let out = entries
.iter()
.enumerate()
.map(|(i, e)| JobEntry {
id: e.id.clone(),
model: e.model.clone(),
state: e.state,
started_at_unix_ms: e.started_at_unix_ms,
position: i,
gpu: e.gpu,
})
.collect();
QueueListing { entries: out }
}
pub fn len(&self) -> usize {
self.inner.read().unwrap_or_else(|e| e.into_inner()).len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_appends_in_fifo_order_with_queued_state() {
let reg = JobRegistry::new();
reg.register("a", "flux-dev:fp16");
reg.register("b", "sdxl:q8");
let snap = reg.snapshot();
assert_eq!(snap.entries.len(), 2);
assert_eq!(snap.entries[0].id, "a");
assert_eq!(snap.entries[0].position, 0);
assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
assert_eq!(snap.entries[1].id, "b");
assert_eq!(snap.entries[1].position, 1);
}
#[test]
fn mark_running_flips_state_and_records_gpu_ordinal() {
let reg = JobRegistry::new();
reg.register("a", "flux-dev:fp16");
reg.mark_running("a", Some(1));
let snap = reg.snapshot();
assert_eq!(snap.entries[0].state, JobLifecycle::Running);
assert_eq!(snap.entries[0].gpu, Some(1));
}
#[test]
fn mark_running_is_a_noop_for_unknown_ids() {
let reg = JobRegistry::new();
reg.register("a", "flux-dev:fp16");
reg.mark_running("not-here", Some(0));
let snap = reg.snapshot();
assert_eq!(snap.entries.len(), 1);
assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
}
#[test]
fn remove_compacts_positions_for_the_survivors() {
let reg = JobRegistry::new();
reg.register("a", "flux-dev:fp16");
reg.register("b", "sdxl:q8");
reg.register("c", "ltx-video:q8");
reg.remove("b");
let snap = reg.snapshot();
assert_eq!(snap.entries.len(), 2);
assert_eq!(snap.entries[0].id, "a");
assert_eq!(snap.entries[0].position, 0);
assert_eq!(snap.entries[1].id, "c");
assert_eq!(snap.entries[1].position, 1);
}
#[test]
fn remove_is_idempotent_and_ignores_empty_ids() {
let reg = JobRegistry::new();
reg.register("a", "flux-dev:fp16");
reg.remove("a");
reg.remove("a"); reg.remove("");
reg.remove("never-existed");
assert!(reg.is_empty());
}
#[test]
fn snapshot_serializes_with_snake_case_state_and_omits_gpu_when_queued() {
let reg = JobRegistry::new();
reg.register("a", "flux-dev:fp16");
let snap = reg.snapshot();
let json = serde_json::to_string(&snap.entries[0]).unwrap();
assert!(json.contains(r#""state":"queued""#), "got: {json}");
assert!(
!json.contains("gpu"),
"queued row leaked a gpu field: {json}"
);
reg.mark_running("a", Some(0));
let snap2 = reg.snapshot();
let json2 = serde_json::to_string(&snap2.entries[0]).unwrap();
assert!(json2.contains(r#""state":"running""#));
assert!(json2.contains(r#""gpu":0"#));
}
}