Skip to main content

armature_queue/
job.rs

1//! Job definition and state management.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use uuid::Uuid;
7
8/// Job unique identifier.
9pub type JobId = Uuid;
10
11/// Job data payload.
12pub type JobData = serde_json::Value;
13
14/// Job priority levels.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
16pub enum JobPriority {
17    /// Lowest priority
18    Low = 0,
19    /// Normal priority (default)
20    #[default]
21    Normal = 1,
22    /// High priority
23    High = 2,
24    /// Critical priority
25    Critical = 3,
26}
27
28/// Job state.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub enum JobState {
31    /// Job is waiting to be processed
32    Pending,
33    /// Job is currently being processed
34    Processing,
35    /// Job completed successfully
36    Completed,
37    /// Job failed and will be retried
38    Failed,
39    /// Job failed permanently (max retries exceeded)
40    Dead,
41}
42
43/// Job status information.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct JobStatus {
46    /// Current state
47    pub state: JobState,
48
49    /// Progress percentage (0-100)
50    pub progress: u8,
51
52    /// Status message
53    pub message: Option<String>,
54
55    /// Error message (if failed)
56    pub error: Option<String>,
57
58    /// Last updated timestamp
59    pub updated_at: DateTime<Utc>,
60}
61
62impl JobStatus {
63    /// Create a new pending status.
64    pub fn pending() -> Self {
65        Self {
66            state: JobState::Pending,
67            progress: 0,
68            message: None,
69            error: None,
70            updated_at: Utc::now(),
71        }
72    }
73
74    /// Create a processing status.
75    pub fn processing() -> Self {
76        Self {
77            state: JobState::Processing,
78            progress: 0,
79            message: None,
80            error: None,
81            updated_at: Utc::now(),
82        }
83    }
84
85    /// Create a completed status.
86    pub fn completed() -> Self {
87        Self {
88            state: JobState::Completed,
89            progress: 100,
90            message: None,
91            error: None,
92            updated_at: Utc::now(),
93        }
94    }
95
96    /// Create a failed status.
97    pub fn failed(error: String) -> Self {
98        Self {
99            state: JobState::Failed,
100            progress: 0,
101            message: None,
102            error: Some(error),
103            updated_at: Utc::now(),
104        }
105    }
106
107    /// Create a dead status.
108    pub fn dead(error: String) -> Self {
109        Self {
110            state: JobState::Dead,
111            progress: 0,
112            message: None,
113            error: Some(error),
114            updated_at: Utc::now(),
115        }
116    }
117
118    /// Update progress.
119    pub fn with_progress(mut self, progress: u8) -> Self {
120        self.progress = progress.min(100);
121        self.updated_at = Utc::now();
122        self
123    }
124
125    /// Update message.
126    pub fn with_message(mut self, message: impl Into<String>) -> Self {
127        self.message = Some(message.into());
128        self.updated_at = Utc::now();
129        self
130    }
131}
132
133/// A job to be processed.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct Job {
136    /// Unique job identifier
137    pub id: JobId,
138
139    /// Job type/name
140    pub job_type: String,
141
142    /// Job payload data
143    pub data: JobData,
144
145    /// Job priority
146    pub priority: JobPriority,
147
148    /// Job status
149    pub status: JobStatus,
150
151    /// Number of attempts
152    pub attempts: u32,
153
154    /// Maximum number of retry attempts
155    pub max_attempts: u32,
156
157    /// Queue name
158    pub queue: String,
159
160    /// When the job was created
161    pub created_at: DateTime<Utc>,
162
163    /// When the job should be processed (for delayed jobs)
164    pub scheduled_at: Option<DateTime<Utc>>,
165
166    /// When the job was started
167    pub started_at: Option<DateTime<Utc>>,
168
169    /// When the job completed/failed
170    pub completed_at: Option<DateTime<Utc>>,
171
172    /// Job metadata
173    pub metadata: HashMap<String, String>,
174}
175
176impl Job {
177    /// Create a new job.
178    pub fn new(queue: impl Into<String>, job_type: impl Into<String>, data: JobData) -> Self {
179        Self {
180            id: Uuid::new_v4(),
181            job_type: job_type.into(),
182            data,
183            priority: JobPriority::default(),
184            status: JobStatus::pending(),
185            attempts: 0,
186            max_attempts: 3,
187            queue: queue.into(),
188            created_at: Utc::now(),
189            scheduled_at: None,
190            started_at: None,
191            completed_at: None,
192            metadata: HashMap::new(),
193        }
194    }
195
196    /// Set job priority.
197    pub fn with_priority(mut self, priority: JobPriority) -> Self {
198        self.priority = priority;
199        self
200    }
201
202    /// Set max retry attempts.
203    pub fn with_max_attempts(mut self, max_attempts: u32) -> Self {
204        self.max_attempts = max_attempts;
205        self
206    }
207
208    /// Schedule the job for later.
209    pub fn schedule_at(mut self, time: DateTime<Utc>) -> Self {
210        self.scheduled_at = Some(time);
211        self
212    }
213
214    /// Schedule the job after a delay.
215    pub fn schedule_after(mut self, duration: chrono::Duration) -> Self {
216        self.scheduled_at = Some(Utc::now() + duration);
217        self
218    }
219
220    /// Add metadata.
221    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
222        self.metadata.insert(key.into(), value.into());
223        self
224    }
225
226    /// Check if the job is ready to be processed.
227    pub fn is_ready(&self) -> bool {
228        if let Some(scheduled_at) = self.scheduled_at {
229            Utc::now() >= scheduled_at
230        } else {
231            true
232        }
233    }
234
235    /// Check if the job can be retried.
236    pub fn can_retry(&self) -> bool {
237        self.attempts < self.max_attempts
238    }
239
240    /// Mark job as processing.
241    pub fn start_processing(&mut self) {
242        self.status = JobStatus::processing();
243        self.started_at = Some(Utc::now());
244        self.attempts += 1;
245    }
246
247    /// Mark job as completed.
248    pub fn complete(&mut self) {
249        self.status = JobStatus::completed();
250        self.completed_at = Some(Utc::now());
251    }
252
253    /// Mark job as failed.
254    pub fn fail(&mut self, error: String) {
255        if self.can_retry() {
256            self.status = JobStatus::failed(error);
257        } else {
258            self.status = JobStatus::dead(error);
259            self.completed_at = Some(Utc::now());
260        }
261    }
262
263    /// Update job progress.
264    pub fn update_progress(&mut self, progress: u8, message: Option<String>) {
265        self.status.progress = progress.min(100);
266        self.status.message = message;
267        self.status.updated_at = Utc::now();
268    }
269
270    /// Calculate backoff delay for retry.
271    pub fn backoff_delay(&self) -> chrono::Duration {
272        // Exponential backoff: 2^(attempts-1) seconds, capped at 1 hour.
273        //
274        // The exponent is clamped to 20 (2^20 == 1_048_576, comfortably above
275        // the 3600s cap) before the power is taken, and `saturating_pow` is
276        // used besides, so an unbounded `attempts` (e.g. a job retried past
277        // `u32::MAX / 2` times) can never overflow `i64` or wrap negative --
278        // which would otherwise defeat the cap by making the "backoff" a
279        // negative/zero delay and triggering an immediate retry storm.
280        let exponent = self.attempts.saturating_sub(1).min(20);
281        let seconds = 2_i64.saturating_pow(exponent);
282        chrono::Duration::seconds(seconds.min(3600)) // Max 1 hour
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_job_creation() {
292        let job = Job::new(
293            "default",
294            "send_email",
295            serde_json::json!({"to": "test@example.com"}),
296        );
297
298        assert_eq!(job.queue, "default");
299        assert_eq!(job.job_type, "send_email");
300        assert_eq!(job.attempts, 0);
301        assert_eq!(job.priority, JobPriority::Normal);
302    }
303
304    #[test]
305    fn test_job_builder() {
306        let job = Job::new("default", "task", serde_json::json!({}))
307            .with_priority(JobPriority::High)
308            .with_max_attempts(5)
309            .with_metadata("user_id", "123");
310
311        assert_eq!(job.priority, JobPriority::High);
312        assert_eq!(job.max_attempts, 5);
313        assert_eq!(job.metadata.get("user_id"), Some(&"123".to_string()));
314    }
315
316    #[test]
317    fn test_job_ready() {
318        let mut job = Job::new("default", "task", serde_json::json!({}));
319        assert!(job.is_ready());
320
321        job = job.schedule_at(Utc::now() + chrono::Duration::hours(1));
322        assert!(!job.is_ready());
323    }
324
325    #[test]
326    fn test_job_retry_logic() {
327        let mut job = Job::new("default", "task", serde_json::json!({}));
328        job.max_attempts = 3;
329
330        assert!(job.can_retry());
331
332        job.start_processing();
333        job.fail("Error 1".to_string());
334        assert!(job.can_retry());
335        assert_eq!(job.status.state, JobState::Failed);
336
337        job.start_processing();
338        job.fail("Error 2".to_string());
339        assert!(job.can_retry());
340
341        job.start_processing();
342        job.fail("Error 3".to_string());
343        assert!(!job.can_retry());
344        assert_eq!(job.status.state, JobState::Dead);
345    }
346
347    #[test]
348    fn test_backoff_delay() {
349        let mut job = Job::new("default", "task", serde_json::json!({}));
350
351        job.attempts = 1;
352        assert_eq!(job.backoff_delay(), chrono::Duration::seconds(1));
353
354        job.attempts = 2;
355        assert_eq!(job.backoff_delay(), chrono::Duration::seconds(2));
356
357        job.attempts = 3;
358        assert_eq!(job.backoff_delay(), chrono::Duration::seconds(4));
359
360        job.attempts = 10;
361        assert_eq!(job.backoff_delay(), chrono::Duration::seconds(512));
362    }
363
364    #[test]
365    fn test_backoff_delay_large_attempts_does_not_panic() {
366        // Pre-fix this computed 2_i64.pow(attempts - 1) BEFORE the .min(3600)
367        // cap, so attempts >= 63 would overflow i64 (panic in debug, wrap to
368        // negative in release). Confirm the cap holds even at absurd attempt
369        // counts, with no panic.
370        let mut job = Job::new("default", "task", serde_json::json!({}));
371
372        job.attempts = 100;
373        assert_eq!(job.backoff_delay(), chrono::Duration::seconds(3600));
374
375        job.attempts = u32::MAX;
376        assert_eq!(job.backoff_delay(), chrono::Duration::seconds(3600));
377    }
378
379    #[test]
380    fn test_job_priority_levels() {
381        let low =
382            Job::new("default", "task", serde_json::json!({})).with_priority(JobPriority::Low);
383        let normal =
384            Job::new("default", "task", serde_json::json!({})).with_priority(JobPriority::Normal);
385        let high =
386            Job::new("default", "task", serde_json::json!({})).with_priority(JobPriority::High);
387        let critical =
388            Job::new("default", "task", serde_json::json!({})).with_priority(JobPriority::Critical);
389
390        assert_eq!(low.priority, JobPriority::Low);
391        assert_eq!(normal.priority, JobPriority::Normal);
392        assert_eq!(high.priority, JobPriority::High);
393        assert_eq!(critical.priority, JobPriority::Critical);
394    }
395
396    #[test]
397    fn test_job_metadata() {
398        let job = Job::new("default", "task", serde_json::json!({}))
399            .with_metadata("key1", "value1")
400            .with_metadata("key2", "value2");
401
402        assert_eq!(job.metadata.len(), 2);
403        assert_eq!(job.metadata.get("key1"), Some(&"value1".to_string()));
404        assert_eq!(job.metadata.get("key2"), Some(&"value2".to_string()));
405    }
406
407    #[test]
408    fn test_job_schedule_at() {
409        let future = Utc::now() + chrono::Duration::hours(2);
410        let job = Job::new("default", "task", serde_json::json!({})).schedule_at(future);
411
412        assert!(!job.is_ready());
413        assert!(job.scheduled_at.is_some());
414    }
415
416    #[test]
417    fn test_job_scheduled_at_in_future() {
418        let future = Utc::now() + chrono::Duration::minutes(30);
419        let job = Job::new("default", "task", serde_json::json!({})).schedule_at(future);
420
421        assert!(!job.is_ready());
422        assert!(job.scheduled_at.is_some());
423    }
424
425    #[test]
426    fn test_job_status_transitions() {
427        let mut job = Job::new("default", "task", serde_json::json!({}));
428
429        assert_eq!(job.status.state, JobState::Pending);
430
431        job.start_processing();
432        assert_eq!(job.status.state, JobState::Processing);
433
434        job.complete();
435        assert_eq!(job.status.state, JobState::Completed);
436    }
437
438    #[test]
439    fn test_job_failure_tracking() {
440        let mut job = Job::new("default", "task", serde_json::json!({}));
441
442        job.start_processing();
443        job.fail("First error".to_string());
444
445        assert_eq!(job.status.state, JobState::Failed);
446        assert_eq!(job.status.error, Some("First error".to_string()));
447        assert_eq!(job.attempts, 1);
448    }
449
450    #[test]
451    fn test_job_max_attempts() {
452        let job = Job::new("default", "task", serde_json::json!({})).with_max_attempts(10);
453
454        assert_eq!(job.max_attempts, 10);
455    }
456
457    #[test]
458    fn test_job_default_max_attempts() {
459        let job = Job::new("default", "task", serde_json::json!({}));
460        assert_eq!(job.max_attempts, 3);
461    }
462
463    #[test]
464    fn test_job_can_retry_with_zero_max_attempts() {
465        let mut job = Job::new("default", "task", serde_json::json!({})).with_max_attempts(0);
466
467        job.start_processing();
468        job.fail("Error".to_string());
469
470        assert!(!job.can_retry());
471    }
472
473    #[test]
474    fn test_job_id_uniqueness() {
475        let job1 = Job::new("default", "task", serde_json::json!({}));
476        let job2 = Job::new("default", "task", serde_json::json!({}));
477
478        assert_ne!(job1.id, job2.id);
479    }
480
481    #[test]
482    fn test_job_timestamps() {
483        let before = Utc::now();
484        let job = Job::new("default", "task", serde_json::json!({}));
485        let after = Utc::now();
486
487        assert!(job.created_at >= before);
488        assert!(job.created_at <= after);
489    }
490
491    #[test]
492    fn test_job_complete_sets_state() {
493        let mut job = Job::new("default", "task", serde_json::json!({}));
494
495        job.start_processing();
496        job.complete();
497
498        assert_eq!(job.status.state, JobState::Completed);
499    }
500
501    #[test]
502    fn test_job_ready_with_past_schedule() {
503        let past = Utc::now() - chrono::Duration::hours(1);
504        let job = Job::new("default", "task", serde_json::json!({})).schedule_at(past);
505
506        assert!(job.is_ready());
507    }
508
509    #[test]
510    fn test_job_serialization_data() {
511        let data = serde_json::json!({
512            "email": "test@example.com",
513            "subject": "Test",
514            "count": 42
515        });
516
517        let job = Job::new("default", "send_email", data.clone());
518        assert_eq!(job.data, data);
519    }
520
521    #[test]
522    fn test_job_priority_ordering() {
523        assert!(JobPriority::Low < JobPriority::Normal);
524        assert!(JobPriority::Normal < JobPriority::High);
525        assert!(JobPriority::High < JobPriority::Critical);
526    }
527
528    #[test]
529    fn test_backoff_delay_exponential_growth() {
530        let mut job = Job::new("default", "task", serde_json::json!({}));
531
532        let delays: Vec<i64> = (1..=5)
533            .map(|attempt| {
534                job.attempts = attempt;
535                job.backoff_delay().num_seconds()
536            })
537            .collect();
538
539        // Verify exponential growth
540        assert!(delays[0] < delays[1]);
541        assert!(delays[1] < delays[2]);
542        assert!(delays[2] < delays[3]);
543        assert!(delays[3] < delays[4]);
544    }
545
546    #[test]
547    fn test_job_state_dead_after_max_retries() {
548        let mut job = Job::new("default", "task", serde_json::json!({})).with_max_attempts(2);
549
550        job.start_processing();
551        job.fail("Error 1".to_string());
552        assert_eq!(job.status.state, JobState::Failed);
553
554        job.start_processing();
555        job.fail("Error 2".to_string());
556        assert_eq!(job.status.state, JobState::Dead);
557    }
558
559    #[test]
560    fn test_job_metadata_overwrite() {
561        let job = Job::new("default", "task", serde_json::json!({}))
562            .with_metadata("key", "value1")
563            .with_metadata("key", "value2");
564
565        assert_eq!(job.metadata.get("key"), Some(&"value2".to_string()));
566    }
567}