use serde::Serialize;
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::Notify;
#[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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_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>,
target_gpu: Option<usize>,
cancel: Arc<Notify>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetGpuUpdateError {
NotFound,
AlreadyRunning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueuedJobCancelError {
NotFound,
AlreadyRunning,
}
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>) -> Arc<Notify> {
self.register_with_target_gpu(id, model, None)
}
pub fn register_with_target_gpu(
&self,
id: impl Into<String>,
model: impl Into<String>,
target_gpu: Option<usize>,
) -> Arc<Notify> {
let started_at_unix_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let cancel = Arc::new(Notify::new());
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,
target_gpu,
cancel: cancel.clone(),
});
cancel
}
pub fn cancel_queued(&self, id: &str) -> Result<(), QueuedJobCancelError> {
let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
let Some(pos) = entries.iter().position(|e| e.id == id) else {
return Err(QueuedJobCancelError::NotFound);
};
if entries[pos].state == JobLifecycle::Running {
return Err(QueuedJobCancelError::AlreadyRunning);
}
let entry = entries.remove(pos);
entry.cancel.notify_one();
Ok(())
}
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;
e.target_gpu = None;
}
}
pub fn set_target_gpu(
&self,
id: &str,
target_gpu: Option<usize>,
) -> Result<(), TargetGpuUpdateError> {
let mut entries = self.inner.write().unwrap_or_else(|e| e.into_inner());
let Some(e) = entries.iter_mut().find(|e| e.id == id) else {
return Err(TargetGpuUpdateError::NotFound);
};
if e.state == JobLifecycle::Running {
return Err(TargetGpuUpdateError::AlreadyRunning);
}
e.target_gpu = target_gpu;
Ok(())
}
pub fn target_gpu(&self, id: &str) -> Option<Option<usize>> {
let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
entries.iter().find(|e| e.id == id).map(|e| e.target_gpu)
}
pub fn entry(&self, id: &str) -> Option<JobEntry> {
let entries = self.inner.read().unwrap_or_else(|e| e.into_inner());
entries.iter().enumerate().find_map(|(i, e)| {
(e.id == id).then(|| 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,
target_gpu: e.target_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,
target_gpu: e.target_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 queued_entries_can_carry_target_gpu_metadata() {
let reg = JobRegistry::new();
reg.register_with_target_gpu("a", "flux-dev:fp16", Some(1));
let snap = reg.snapshot();
assert_eq!(snap.entries[0].state, JobLifecycle::Queued);
assert_eq!(snap.entries[0].target_gpu, Some(1));
assert_eq!(snap.entries[0].gpu, None);
}
#[test]
fn target_gpu_updates_only_apply_to_queued_entries() {
let reg = JobRegistry::new();
reg.register("a", "flux-dev:fp16");
reg.set_target_gpu("a", Some(1)).unwrap();
assert_eq!(reg.target_gpu("a"), Some(Some(1)));
reg.mark_running("a", Some(1));
let err = reg.set_target_gpu("a", None).unwrap_err();
assert_eq!(err, TargetGpuUpdateError::AlreadyRunning);
assert_eq!(reg.target_gpu("a"), Some(None));
}
#[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 cancel_queued_removes_the_entry() {
let reg = JobRegistry::new();
reg.register("a", "flux-dev:fp16");
reg.register("b", "sdxl:q8");
reg.cancel_queued("a").unwrap();
let snap = reg.snapshot();
assert_eq!(snap.entries.len(), 1);
assert_eq!(snap.entries[0].id, "b");
assert_eq!(snap.entries[0].position, 0);
}
#[test]
fn cancel_queued_rejects_running_jobs_and_keeps_the_entry() {
let reg = JobRegistry::new();
reg.register("a", "flux-dev:fp16");
reg.mark_running("a", Some(0));
let err = reg.cancel_queued("a").unwrap_err();
assert_eq!(err, QueuedJobCancelError::AlreadyRunning);
assert_eq!(reg.len(), 1, "running entry must survive a cancel attempt");
}
#[test]
fn cancel_queued_unknown_id_is_not_found() {
let reg = JobRegistry::new();
let err = reg.cancel_queued("never-existed").unwrap_err();
assert_eq!(err, QueuedJobCancelError::NotFound);
}
#[tokio::test]
async fn cancel_queued_signals_the_registered_waiter() {
let reg = JobRegistry::new();
let cancel = reg.register("a", "flux-dev:fp16");
reg.cancel_queued("a").unwrap();
tokio::time::timeout(std::time::Duration::from_secs(1), cancel.notified())
.await
.expect("cancel signal must resolve the waiter");
}
#[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"#));
}
}