Skip to main content

rustlavel_queue/
progress.rs

1//! Where a job is, as far as somebody outside the worker can tell.
2//!
3//! A job that takes a minute is a spinner for a minute unless it says how far
4//! it has got. The job calls [`JobContext::progress`]; a request handler in
5//! the web process reads [`ProgressStore::get`]; the two share a store and
6//! nothing else — which is the point, because the worker is usually another
7//! process on another machine.
8//!
9//! Progress is a *hint*. It is written with no transaction and read with no
10//! lock, and a job that crashes between two reports leaves the last one
11//! standing. The worker closes that gap: it marks the job finished on success
12//! and failed on the final attempt, whatever the job last said.
13
14use 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/// One job's position.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Progress {
25    /// 0 to 100. Clamped on the way in, because a job that reports 130% has
26    /// a bug and a bar that overflows its box does not help find it.
27    pub percent: u8,
28    /// What the job is doing now, in words a person can read: "transcribing",
29    /// "rendering clip 3 of 7".
30    pub stage: String,
31    /// Unix seconds.
32    pub updated_at: u64,
33    /// Set by the worker, not the job, when the job's `handle` returned.
34    pub finished: bool,
35    /// Set by the worker on the last failed attempt, with the error.
36    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
51/// Where progress lives. Keyed by job id, and by chain id for a job that is a
52/// step in one.
53pub 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    /// Drop records older than `before` (unix seconds). Housekeeping.
57    fn purge<'a>(&'a self, before: u64) -> ProgressFuture<'a, usize>;
58}
59
60/// Progress kept in this process. Right for tests and for a worker that runs
61/// inside the web process; wrong once the worker is a separate process, which
62/// is the arrangement this exists for.
63#[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/// What a running job is handed: who it is, and where to say how far it has
97/// got.
98#[derive(Clone)]
99pub struct JobContext {
100    /// The driver's id for this job.
101    pub id: String,
102    /// The chain this job is a step of, when it is one. Progress reported
103    /// here is recorded under the chain as well, so a UI following a pipeline
104    /// has one key to watch.
105    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    /// A context for a job run outside the worker — a test calling `handle`
115    /// directly. Reports go nowhere.
116    pub fn detached() -> JobContext {
117        JobContext { id: "detached".into(), chain: None, store: None }
118    }
119
120    /// Say how far along the job is.
121    ///
122    /// Never fails the job. A progress store that is down should cost the
123    /// user a stale bar, not a failed render — so the error is logged and
124    /// swallowed here rather than returned.
125    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    /// The job's own key, and the chain's when there is one.
142    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    /// 130% is a bug in the job, and a bar that overflows its box does not
172    /// help anybody find it.
173    #[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    /// A job run by hand in a test has nowhere to report and must not care.
182    #[tokio::test]
183    async fn a_detached_context_swallows_reports() {
184        JobContext::detached().progress(50, "fine").await;
185    }
186}