Skip to main content

oximedia_distributed/
job_tracker.rs

1//! Distributed job tracking.
2//!
3//! Provides a lifecycle-aware store for distributed encoding jobs with
4//! progress percentage tracking and ETA estimation.
5
6/// State machine for a distributed job.
7#[allow(dead_code)]
8#[derive(Debug, Clone)]
9pub enum JobState {
10    Queued,
11    Assigned { worker_id: u64 },
12    Running { progress_pct: f32 },
13    Completed { output: String },
14    Failed { error: String },
15    Cancelled,
16}
17
18impl JobState {
19    fn is_completed(&self) -> bool {
20        matches!(self, JobState::Completed { .. })
21    }
22
23    fn is_failed(&self) -> bool {
24        matches!(self, JobState::Failed { .. })
25    }
26
27    fn is_queued(&self) -> bool {
28        matches!(self, JobState::Queued)
29    }
30
31    fn is_running(&self) -> bool {
32        matches!(self, JobState::Running { .. })
33    }
34}
35
36/// Progress snapshot used for ETA estimation.
37#[derive(Debug, Clone)]
38struct ProgressSample {
39    /// Progress percentage at the time of sampling (0.0–100.0).
40    progress_pct: f32,
41    /// Timestamp of the sample (milliseconds since epoch).
42    timestamp_ms: u64,
43}
44
45/// A single distributed encoding job with progress and ETA tracking.
46#[allow(dead_code)]
47#[derive(Debug, Clone)]
48pub struct DistributedJob {
49    pub id: u64,
50    pub name: String,
51    pub state: JobState,
52    pub created_at: u64,
53    pub updated_at: u64,
54    pub priority: i32,
55    /// Time the job was first started (ms since epoch), set on first progress update.
56    started_at_ms: Option<u64>,
57    /// Ring buffer of recent progress samples for ETA rolling average.
58    progress_samples: Vec<ProgressSample>,
59}
60
61impl DistributedJob {
62    /// Create a new job in the `Queued` state.
63    #[must_use]
64    pub fn new(id: u64, name: &str, priority: i32, now: u64) -> Self {
65        Self {
66            id,
67            name: name.to_string(),
68            state: JobState::Queued,
69            created_at: now,
70            updated_at: now,
71            priority,
72            started_at_ms: None,
73            progress_samples: Vec::new(),
74        }
75    }
76
77    /// Transition to `Assigned`.
78    pub fn assign(&mut self, worker_id: u64, now: u64) {
79        self.state = JobState::Assigned { worker_id };
80        self.updated_at = now;
81    }
82
83    /// Transition to `Running` with the given progress percentage.
84    ///
85    /// Stores a progress sample for ETA computation and sets `started_at_ms`
86    /// on the first call.
87    pub fn update_progress(&mut self, pct: f32, now: u64) {
88        let clamped = pct.clamp(0.0, 100.0);
89        // Record start time on first progress update
90        if self.started_at_ms.is_none() {
91            self.started_at_ms = Some(now);
92        }
93        // Keep up to the last 8 samples for a rolling average
94        const MAX_SAMPLES: usize = 8;
95        self.progress_samples.push(ProgressSample {
96            progress_pct: clamped,
97            timestamp_ms: now,
98        });
99        if self.progress_samples.len() > MAX_SAMPLES {
100            self.progress_samples.remove(0);
101        }
102        self.state = JobState::Running {
103            progress_pct: clamped,
104        };
105        self.updated_at = now;
106    }
107
108    /// Current progress as a percentage (0.0–100.0).
109    ///
110    /// Returns `0.0` for jobs not yet in the `Running` state.
111    #[must_use]
112    pub fn progress_pct(&self) -> f32 {
113        match self.state {
114            JobState::Running { progress_pct } => progress_pct,
115            JobState::Completed { .. } => 100.0,
116            _ => 0.0,
117        }
118    }
119
120    /// Estimated time to completion in milliseconds.
121    ///
122    /// Uses the most recent two progress samples to compute the current
123    /// encoding rate, then extrapolates to 100 %.  Returns `None` when
124    /// there are fewer than two samples, the progress is already at 100 %,
125    /// or the elapsed time is zero.
126    #[must_use]
127    pub fn eta_ms(&self) -> Option<u64> {
128        if self.progress_samples.len() < 2 {
129            return None;
130        }
131        let oldest = &self.progress_samples[0];
132        let newest = self.progress_samples.last()?;
133        let pct_delta = newest.progress_pct - oldest.progress_pct;
134        if pct_delta <= 0.0 {
135            return None;
136        }
137        let ms_delta = newest.timestamp_ms.saturating_sub(oldest.timestamp_ms);
138        if ms_delta == 0 {
139            return None;
140        }
141        let remaining_pct = 100.0_f32 - newest.progress_pct;
142        if remaining_pct <= 0.0 {
143            return Some(0);
144        }
145        // rate = pct_delta / ms_delta  →  eta_ms = remaining_pct / rate
146        let eta = (remaining_pct / pct_delta) * ms_delta as f32;
147        Some(eta.round() as u64)
148    }
149
150    /// Transition to `Completed`.
151    pub fn complete(&mut self, output: &str, now: u64) {
152        self.state = JobState::Completed {
153            output: output.to_string(),
154        };
155        self.updated_at = now;
156    }
157
158    /// Transition to `Failed`.
159    pub fn fail(&mut self, error: &str, now: u64) {
160        self.state = JobState::Failed {
161            error: error.to_string(),
162        };
163        self.updated_at = now;
164    }
165
166    /// Transition to `Cancelled`.
167    pub fn cancel(&mut self, now: u64) {
168        self.state = JobState::Cancelled;
169        self.updated_at = now;
170    }
171}
172
173/// Stores and queries distributed jobs.
174#[allow(dead_code)]
175#[derive(Debug, Default)]
176pub struct JobTracker {
177    jobs: Vec<DistributedJob>,
178}
179
180impl JobTracker {
181    /// Create an empty tracker.
182    #[must_use]
183    pub fn new() -> Self {
184        Self { jobs: Vec::new() }
185    }
186
187    /// Add a job to the tracker.
188    pub fn submit(&mut self, job: DistributedJob) {
189        self.jobs.push(job);
190    }
191
192    /// Look up a job by ID (immutable).
193    #[must_use]
194    pub fn get(&self, id: u64) -> Option<&DistributedJob> {
195        self.jobs.iter().find(|j| j.id == id)
196    }
197
198    /// Look up a job by ID (mutable).
199    pub fn get_mut(&mut self, id: u64) -> Option<&mut DistributedJob> {
200        self.jobs.iter_mut().find(|j| j.id == id)
201    }
202
203    /// Return all jobs currently in the `Queued` state.
204    #[must_use]
205    pub fn queued_jobs(&self) -> Vec<&DistributedJob> {
206        self.jobs.iter().filter(|j| j.state.is_queued()).collect()
207    }
208
209    /// Return all jobs currently in the `Running` state.
210    #[must_use]
211    pub fn running_jobs(&self) -> Vec<&DistributedJob> {
212        self.jobs.iter().filter(|j| j.state.is_running()).collect()
213    }
214
215    /// Return all jobs currently in the `Failed` state.
216    #[must_use]
217    pub fn failed_jobs(&self) -> Vec<&DistributedJob> {
218        self.jobs.iter().filter(|j| j.state.is_failed()).collect()
219    }
220
221    /// Fraction of jobs that completed successfully.
222    ///
223    /// Returns `0.0` when no jobs have been submitted.
224    #[must_use]
225    pub fn completion_rate(&self) -> f64 {
226        if self.jobs.is_empty() {
227            return 0.0;
228        }
229        let completed = self.jobs.iter().filter(|j| j.state.is_completed()).count();
230        completed as f64 / self.jobs.len() as f64
231    }
232
233    /// Return the total number of tracked jobs.
234    #[must_use]
235    pub fn total_jobs(&self) -> usize {
236        self.jobs.len()
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn job(id: u64) -> DistributedJob {
245        DistributedJob::new(id, &format!("job-{}", id), 0, 1000)
246    }
247
248    #[test]
249    fn test_new_job_queued() {
250        let j = job(1);
251        assert!(j.state.is_queued());
252        assert_eq!(j.id, 1);
253        assert_eq!(j.name, "job-1");
254    }
255
256    #[test]
257    fn test_assign_job() {
258        let mut j = job(1);
259        j.assign(42, 2000);
260        assert!(matches!(j.state, JobState::Assigned { worker_id: 42 }));
261        assert_eq!(j.updated_at, 2000);
262    }
263
264    #[test]
265    fn test_update_progress() {
266        let mut j = job(1);
267        j.assign(1, 1001);
268        j.update_progress(55.5, 1002);
269        assert!(matches!(
270            j.state,
271            JobState::Running { progress_pct } if (progress_pct - 55.5).abs() < 1e-4
272        ));
273    }
274
275    #[test]
276    fn test_progress_clamps() {
277        let mut j = job(1);
278        j.update_progress(200.0, 1001);
279        assert!(matches!(
280            j.state,
281            JobState::Running { progress_pct } if (progress_pct - 100.0).abs() < 1e-4
282        ));
283    }
284
285    #[test]
286    fn test_complete_job() {
287        let mut j = job(1);
288        j.complete("s3://bucket/output.mp4", 3000);
289        assert!(j.state.is_completed());
290        assert!(matches!(j.state, JobState::Completed { ref output } if output.contains("mp4")));
291    }
292
293    #[test]
294    fn test_fail_job() {
295        let mut j = job(1);
296        j.fail("out of memory", 4000);
297        assert!(j.state.is_failed());
298    }
299
300    #[test]
301    fn test_cancel_job() {
302        let mut j = job(1);
303        j.cancel(5000);
304        assert!(matches!(j.state, JobState::Cancelled));
305    }
306
307    #[test]
308    fn test_tracker_submit_and_get() {
309        let mut t = JobTracker::new();
310        t.submit(job(10));
311        let j = t.get(10).expect("get should return a value");
312        assert_eq!(j.id, 10);
313    }
314
315    #[test]
316    fn test_tracker_get_mut() {
317        let mut t = JobTracker::new();
318        t.submit(job(1));
319        t.get_mut(1)
320            .expect("get_mut should return a value")
321            .assign(99, 2000);
322        assert!(matches!(
323            t.get(1).expect("get should return a value").state,
324            JobState::Assigned { .. }
325        ));
326    }
327
328    #[test]
329    fn test_tracker_queued_jobs() {
330        let mut t = JobTracker::new();
331        t.submit(job(1));
332        t.submit(job(2));
333        t.get_mut(1)
334            .expect("get_mut should return a value")
335            .assign(7, 1001);
336        assert_eq!(t.queued_jobs().len(), 1);
337        assert_eq!(t.queued_jobs()[0].id, 2);
338    }
339
340    #[test]
341    fn test_tracker_running_jobs() {
342        let mut t = JobTracker::new();
343        t.submit(job(1));
344        t.submit(job(2));
345        t.get_mut(1)
346            .expect("get_mut should return a value")
347            .update_progress(50.0, 1001);
348        assert_eq!(t.running_jobs().len(), 1);
349    }
350
351    #[test]
352    fn test_tracker_failed_jobs() {
353        let mut t = JobTracker::new();
354        t.submit(job(1));
355        t.submit(job(2));
356        t.get_mut(2)
357            .expect("get_mut should return a value")
358            .fail("error", 1001);
359        assert_eq!(t.failed_jobs().len(), 1);
360        assert_eq!(t.failed_jobs()[0].id, 2);
361    }
362
363    #[test]
364    fn test_completion_rate_empty() {
365        let t = JobTracker::new();
366        assert_eq!(t.completion_rate(), 0.0);
367    }
368
369    #[test]
370    fn test_completion_rate_all_complete() {
371        let mut t = JobTracker::new();
372        for i in 1..=3 {
373            t.submit(job(i));
374            t.get_mut(i)
375                .expect("get_mut should return a value")
376                .complete("out", 2000);
377        }
378        assert!((t.completion_rate() - 1.0).abs() < f64::EPSILON);
379    }
380
381    #[test]
382    fn test_completion_rate_partial() {
383        let mut t = JobTracker::new();
384        t.submit(job(1));
385        t.submit(job(2));
386        t.get_mut(1)
387            .expect("get_mut should return a value")
388            .complete("out", 2000);
389        assert!((t.completion_rate() - 0.5).abs() < f64::EPSILON);
390    }
391
392    #[test]
393    fn test_total_jobs() {
394        let mut t = JobTracker::new();
395        assert_eq!(t.total_jobs(), 0);
396        t.submit(job(1));
397        t.submit(job(2));
398        assert_eq!(t.total_jobs(), 2);
399    }
400
401    // ── Progress and ETA ────────────────────────────────────────────────
402
403    #[test]
404    fn test_progress_pct_queued_is_zero() {
405        let j = job(1);
406        assert!((j.progress_pct() - 0.0).abs() < 1e-5);
407    }
408
409    #[test]
410    fn test_progress_pct_running() {
411        let mut j = job(1);
412        j.update_progress(42.5, 1000);
413        assert!((j.progress_pct() - 42.5).abs() < 1e-5);
414    }
415
416    #[test]
417    fn test_progress_pct_completed_is_100() {
418        let mut j = job(1);
419        j.complete("out", 2000);
420        assert!((j.progress_pct() - 100.0).abs() < 1e-5);
421    }
422
423    #[test]
424    fn test_progress_clamped_above_100() {
425        let mut j = job(1);
426        j.update_progress(150.0, 1000);
427        assert!((j.progress_pct() - 100.0).abs() < 1e-5);
428    }
429
430    #[test]
431    fn test_eta_single_sample_returns_none() {
432        let mut j = job(1);
433        j.update_progress(10.0, 1000);
434        assert!(j.eta_ms().is_none());
435    }
436
437    #[test]
438    fn test_eta_two_samples_estimates() {
439        let mut j = job(1);
440        // 0→25 % in 1000 ms  →  remaining 75 % at same rate = 3000 ms
441        j.update_progress(0.0, 0);
442        j.update_progress(25.0, 1000);
443        let eta = j.eta_ms().expect("eta should be estimated");
444        assert!((eta as i64 - 3000).abs() < 100, "eta={eta}");
445    }
446
447    #[test]
448    fn test_eta_at_100_pct_is_zero() {
449        let mut j = job(1);
450        j.update_progress(50.0, 0);
451        j.update_progress(100.0, 1000);
452        let eta = j.eta_ms().expect("eta should be Some");
453        assert_eq!(eta, 0);
454    }
455
456    #[test]
457    fn test_eta_zero_delta_returns_none() {
458        let mut j = job(1);
459        j.update_progress(50.0, 1000);
460        j.update_progress(50.0, 2000); // no progress
461        assert!(j.eta_ms().is_none());
462    }
463
464    #[test]
465    fn test_started_at_set_on_first_progress() {
466        let mut j = job(1);
467        assert!(j.started_at_ms.is_none());
468        j.update_progress(5.0, 500);
469        assert_eq!(j.started_at_ms, Some(500));
470        // Second call should NOT update started_at
471        j.update_progress(10.0, 1000);
472        assert_eq!(j.started_at_ms, Some(500));
473    }
474}