Skip to main content

camel_component_cron/
tokio_impl.rs

1//! TokioCronService — default in-process CronService implementation.
2
3use camel_component_api::{CamelError, CronCallback, CronFire, CronSchedule, CronService};
4use tracing::debug;
5
6/// Default in-process cron scheduler using tokio::time.
7pub struct TokioCronService;
8
9#[async_trait::async_trait]
10impl CronService for TokioCronService {
11    async fn run(
12        &self,
13        schedule: &CronSchedule,
14        callback: CronCallback,
15        cancel: tokio_util::sync::CancellationToken,
16    ) -> Result<(), CamelError> {
17        let cron: cron::Schedule = schedule.expression.parse().map_err(|e| {
18            CamelError::EndpointCreationFailed(format!("invalid cron expression: {}", e))
19        })?;
20        let tz = schedule.time_zone;
21        let mut counter: u64 = 0;
22
23        debug!(
24            trigger = schedule.trigger_id,
25            schedule = schedule.expression,
26            "cron service started"
27        );
28
29        loop {
30            let now = chrono::Utc::now().with_timezone(&tz);
31            let next = cron.upcoming(tz).next().ok_or_else(|| {
32                CamelError::EndpointCreationFailed(
33                    "cron schedule has no future fire times".to_string(),
34                )
35            })?;
36
37            let delay = (next - now).to_std().map_err(|e| {
38                CamelError::EndpointCreationFailed(format!("invalid schedule duration: {}", e))
39            })?;
40
41            debug!(
42                trigger = schedule.trigger_id,
43                next_fire = %next,
44                delay_ms = delay.as_millis(),
45                "cron waiting for next fire"
46            );
47
48            tokio::select! {
49                _ = cancel.cancelled() => {
50                    debug!(trigger = schedule.trigger_id, "cron cancelled, stopping");
51                    return Ok(());
52                }
53                _ = tokio::time::sleep(delay) => {
54                    counter += 1;
55                    let fire = CronFire {
56                        scheduled_at: next.with_timezone(&chrono::Utc),
57                        fired_at: chrono::Utc::now(),
58                        counter,
59                    };
60                    debug!(
61                        trigger = schedule.trigger_id,
62                        counter, "cron firing"
63                    );
64                    callback(fire).await?;
65                }
66            }
67        }
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use std::sync::Arc;
75    use std::sync::atomic::{AtomicU64, Ordering};
76
77    use chrono_tz::Tz;
78
79    fn make_schedule(expr: &str) -> CronSchedule {
80        // Normalize 5-field Unix to cron-crate 6-field format
81        CronSchedule {
82            expression: format!("0 {}", expr),
83            time_zone: Tz::UTC,
84            trigger_id: "test".to_string(),
85            route_id: None,
86        }
87    }
88
89    #[tokio::test]
90    async fn test_cancel_returns_ok() {
91        // Far-future schedule so no fire happens; cancel immediately.
92        let schedule = make_schedule("0 0 1 1 *");
93        let callback: CronCallback = Arc::new(|_fire| Box::pin(async { Ok(()) }));
94        let cancel = tokio_util::sync::CancellationToken::new();
95        let cancel2 = cancel.clone();
96
97        let handle =
98            tokio::spawn(async move { TokioCronService.run(&schedule, callback, cancel2).await });
99
100        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
101        cancel.cancel();
102
103        let result = handle.await.unwrap();
104        assert!(result.is_ok());
105    }
106
107    #[tokio::test]
108    async fn test_misfire_does_not_fire_immediately() {
109        // Far-future schedule. Start, wait briefly, cancel. No fires.
110        let schedule = make_schedule("0 0 1 1 *");
111        let counter = Arc::new(AtomicU64::new(0));
112        let cb_counter = counter.clone();
113        let callback: CronCallback = Arc::new(move |_fire| {
114            let c = cb_counter.clone();
115            Box::pin(async move {
116                c.fetch_add(1, Ordering::SeqCst);
117                Ok(())
118            })
119        });
120        let cancel = tokio_util::sync::CancellationToken::new();
121        let cancel2 = cancel.clone();
122
123        let handle =
124            tokio::spawn(async move { TokioCronService.run(&schedule, callback, cancel2).await });
125
126        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
127        cancel.cancel();
128        let _ = handle.await;
129
130        assert_eq!(
131            counter.load(Ordering::SeqCst),
132            0,
133            "should not fire for future schedule"
134        );
135    }
136
137    #[tokio::test]
138    async fn test_next_fire_computed_from_now() {
139        // For "* * * * *" normalized to "0 * * * * *" (every minute at sec=0),
140        // next fire is within 60 seconds from now.
141        let cron: cron::Schedule = "0 * * * * *".parse().unwrap();
142        let now = chrono::Utc::now().with_timezone(&Tz::UTC);
143        let next = cron.upcoming(Tz::UTC).next().unwrap();
144        let delta = next - now;
145        assert!(
146            delta.num_seconds() >= 0 && delta.num_seconds() < 60,
147            "next fire should be within 60s, got {}s",
148            delta.num_seconds()
149        );
150    }
151
152    #[tokio::test]
153    async fn test_5_field_expression_parsed() {
154        // Verify 5-field cron normalizes and produces upcoming times.
155        // "0 2 * * *" → "0 0 2 * * *" (every day at 02:00 UTC)
156        for (unix_expr, normalized) in &[
157            ("0 2 * * *", "0 0 2 * * *"),
158            ("*/5 * * * *", "0 */5 * * * *"),
159            ("0 9 * * 1", "0 0 9 * * 1"),
160        ] {
161            let cron: cron::Schedule = normalized
162                .parse()
163                .unwrap_or_else(|e| panic!("'{}' (normalized '{}'): {}", unix_expr, normalized, e));
164            let next = cron.upcoming(Tz::UTC).next();
165            assert!(next.is_some(), "'{}' should have upcoming fires", unix_expr);
166        }
167    }
168
169    #[test]
170    fn test_dst_spring_forward_schedule_has_valid_next_fire() {
171        // Spec requirement: a schedule that fires during a DST gap must
172        // still produce a valid next fire (chrono-tz resolves the gap).
173        // "0 0 * * *" normalized = "0 0 0 * * *" (midnight daily).
174        // America/Sao_Paulo springs forward at midnight; chrono-tz resolves.
175        let cron: cron::Schedule = "0 0 0 * * *".parse().unwrap();
176        let tz = Tz::America__Sao_Paulo;
177        let next = cron.upcoming(tz).next();
178        assert!(
179            next.is_some(),
180            "spring-forward schedule must still have a next fire"
181        );
182    }
183}