1#[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#[derive(Debug, Clone)]
38struct ProgressSample {
39 progress_pct: f32,
41 timestamp_ms: u64,
43}
44
45#[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 started_at_ms: Option<u64>,
57 progress_samples: Vec<ProgressSample>,
59}
60
61impl DistributedJob {
62 #[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 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 pub fn update_progress(&mut self, pct: f32, now: u64) {
88 let clamped = pct.clamp(0.0, 100.0);
89 if self.started_at_ms.is_none() {
91 self.started_at_ms = Some(now);
92 }
93 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 #[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 #[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 let eta = (remaining_pct / pct_delta) * ms_delta as f32;
147 Some(eta.round() as u64)
148 }
149
150 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 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 pub fn cancel(&mut self, now: u64) {
168 self.state = JobState::Cancelled;
169 self.updated_at = now;
170 }
171}
172
173#[allow(dead_code)]
175#[derive(Debug, Default)]
176pub struct JobTracker {
177 jobs: Vec<DistributedJob>,
178}
179
180impl JobTracker {
181 #[must_use]
183 pub fn new() -> Self {
184 Self { jobs: Vec::new() }
185 }
186
187 pub fn submit(&mut self, job: DistributedJob) {
189 self.jobs.push(job);
190 }
191
192 #[must_use]
194 pub fn get(&self, id: u64) -> Option<&DistributedJob> {
195 self.jobs.iter().find(|j| j.id == id)
196 }
197
198 pub fn get_mut(&mut self, id: u64) -> Option<&mut DistributedJob> {
200 self.jobs.iter_mut().find(|j| j.id == id)
201 }
202
203 #[must_use]
205 pub fn queued_jobs(&self) -> Vec<&DistributedJob> {
206 self.jobs.iter().filter(|j| j.state.is_queued()).collect()
207 }
208
209 #[must_use]
211 pub fn running_jobs(&self) -> Vec<&DistributedJob> {
212 self.jobs.iter().filter(|j| j.state.is_running()).collect()
213 }
214
215 #[must_use]
217 pub fn failed_jobs(&self) -> Vec<&DistributedJob> {
218 self.jobs.iter().filter(|j| j.state.is_failed()).collect()
219 }
220
221 #[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 #[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 #[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 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); 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 j.update_progress(10.0, 1000);
472 assert_eq!(j.started_at_ms, Some(500));
473 }
474}