camel_component_cron/
tokio_impl.rs1use camel_component_api::{CamelError, CronCallback, CronFire, CronSchedule, CronService};
4use tracing::debug;
5
6pub 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 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 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 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 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 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 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}