rustlavel_queue/
progress.rs1use rustlavel_core::{Json, Result};
15use std::collections::HashMap;
16use std::future::Future;
17use std::pin::Pin;
18use std::sync::{Arc, Mutex};
19
20pub type ProgressFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Progress {
25 pub percent: u8,
28 pub stage: String,
31 pub updated_at: u64,
33 pub finished: bool,
35 pub failed: Option<String>,
37}
38
39impl Progress {
40 pub fn to_json(&self) -> Json {
41 Json::object([
42 ("percent", Json::from(self.percent as i64)),
43 ("stage", Json::from(self.stage.as_str())),
44 ("updated_at", Json::from(self.updated_at as i64)),
45 ("finished", Json::from(self.finished)),
46 ("failed", self.failed.as_deref().map_or(Json::Null, Json::from)),
47 ])
48 }
49}
50
51pub trait ProgressStore: Send + Sync + 'static {
54 fn set<'a>(&'a self, key: &'a str, progress: Progress) -> ProgressFuture<'a, ()>;
55 fn get<'a>(&'a self, key: &'a str) -> ProgressFuture<'a, Option<Progress>>;
56 fn purge<'a>(&'a self, before: u64) -> ProgressFuture<'a, usize>;
58}
59
60#[derive(Default)]
64pub struct MemoryProgress {
65 inner: Mutex<HashMap<String, Progress>>,
66}
67
68impl MemoryProgress {
69 pub fn new() -> MemoryProgress {
70 MemoryProgress::default()
71 }
72}
73
74impl ProgressStore for MemoryProgress {
75 fn set<'a>(&'a self, key: &'a str, progress: Progress) -> ProgressFuture<'a, ()> {
76 Box::pin(async move {
77 self.inner.lock().expect("the progress store is poisoned").insert(key.to_string(), progress);
78 Ok(())
79 })
80 }
81
82 fn get<'a>(&'a self, key: &'a str) -> ProgressFuture<'a, Option<Progress>> {
83 Box::pin(async move { Ok(self.inner.lock().expect("the progress store is poisoned").get(key).cloned()) })
84 }
85
86 fn purge<'a>(&'a self, before: u64) -> ProgressFuture<'a, usize> {
87 Box::pin(async move {
88 let mut held = self.inner.lock().expect("the progress store is poisoned");
89 let before_len = held.len();
90 held.retain(|_, progress| progress.updated_at >= before);
91 Ok(before_len - held.len())
92 })
93 }
94}
95
96#[derive(Clone)]
99pub struct JobContext {
100 pub id: String,
102 pub chain: Option<String>,
106 store: Option<Arc<dyn ProgressStore>>,
107}
108
109impl JobContext {
110 pub fn new(id: impl Into<String>, chain: Option<String>, store: Option<Arc<dyn ProgressStore>>) -> JobContext {
111 JobContext { id: id.into(), chain, store }
112 }
113
114 pub fn detached() -> JobContext {
117 JobContext { id: "detached".into(), chain: None, store: None }
118 }
119
120 pub async fn progress(&self, percent: u8, stage: impl Into<String>) {
126 let Some(store) = &self.store else { return };
127 let progress = Progress {
128 percent: percent.min(100),
129 stage: stage.into(),
130 updated_at: crate::time::unix_now().max(0) as u64,
131 finished: false,
132 failed: None,
133 };
134 for key in self.keys() {
135 if let Err(error) = store.set(&key, progress.clone()).await {
136 rustlavel_core::warn!("queue: could not record progress for {key}: {error}");
137 }
138 }
139 }
140
141 pub fn keys(&self) -> Vec<String> {
143 let mut keys = vec![self.id.clone()];
144 if let Some(chain) = &self.chain {
145 keys.push(format!("chain:{chain}"));
146 }
147 keys
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[tokio::test]
156 async fn progress_is_readable_by_job_id_and_by_chain() {
157 let store: Arc<dyn ProgressStore> = Arc::new(MemoryProgress::new());
158 let ctx = JobContext::new("job-1", Some("chain-9".into()), Some(Arc::clone(&store)));
159
160 ctx.progress(40, "transcribing").await;
161
162 let by_job = store.get("job-1").await.unwrap().expect("recorded");
163 assert_eq!(by_job.percent, 40);
164 assert_eq!(by_job.stage, "transcribing");
165 assert!(!by_job.finished);
166
167 let by_chain = store.get("chain:chain-9").await.unwrap().expect("recorded under the chain too");
168 assert_eq!(by_chain.percent, 40);
169 }
170
171 #[tokio::test]
174 async fn percent_is_clamped() {
175 let store: Arc<dyn ProgressStore> = Arc::new(MemoryProgress::new());
176 let ctx = JobContext::new("j", None, Some(Arc::clone(&store)));
177 ctx.progress(130, "x").await;
178 assert_eq!(store.get("j").await.unwrap().unwrap().percent, 100);
179 }
180
181 #[tokio::test]
183 async fn a_detached_context_swallows_reports() {
184 JobContext::detached().progress(50, "fine").await;
185 }
186}