use camel_component_api::{CamelError, CronCallback, CronFire, CronSchedule, CronService};
use tracing::debug;
pub struct TokioCronService;
#[async_trait::async_trait]
impl CronService for TokioCronService {
async fn run(
&self,
schedule: &CronSchedule,
callback: CronCallback,
cancel: tokio_util::sync::CancellationToken,
) -> Result<(), CamelError> {
let cron: cron::Schedule = schedule.expression.parse().map_err(|e| {
CamelError::EndpointCreationFailed(format!("invalid cron expression: {}", e))
})?;
let tz = schedule.time_zone;
let mut counter: u64 = 0;
debug!(
trigger = schedule.trigger_id,
schedule = schedule.expression,
"cron service started"
);
loop {
let now = chrono::Utc::now().with_timezone(&tz);
let next = cron.upcoming(tz).next().ok_or_else(|| {
CamelError::EndpointCreationFailed(
"cron schedule has no future fire times".to_string(),
)
})?;
let delay = (next - now).to_std().map_err(|e| {
CamelError::EndpointCreationFailed(format!("invalid schedule duration: {}", e))
})?;
debug!(
trigger = schedule.trigger_id,
next_fire = %next,
delay_ms = delay.as_millis(),
"cron waiting for next fire"
);
tokio::select! {
_ = cancel.cancelled() => {
debug!(trigger = schedule.trigger_id, "cron cancelled, stopping");
return Ok(());
}
_ = tokio::time::sleep(delay) => {
counter += 1;
let fire = CronFire {
scheduled_at: next.with_timezone(&chrono::Utc),
fired_at: chrono::Utc::now(),
counter,
};
debug!(
trigger = schedule.trigger_id,
counter, "cron firing"
);
callback(fire).await?;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use chrono_tz::Tz;
fn make_schedule(expr: &str) -> CronSchedule {
CronSchedule {
expression: format!("0 {}", expr),
time_zone: Tz::UTC,
trigger_id: "test".to_string(),
route_id: None,
}
}
#[tokio::test]
async fn test_cancel_returns_ok() {
let schedule = make_schedule("0 0 1 1 *");
let callback: CronCallback = Arc::new(|_fire| Box::pin(async { Ok(()) }));
let cancel = tokio_util::sync::CancellationToken::new();
let cancel2 = cancel.clone();
let handle =
tokio::spawn(async move { TokioCronService.run(&schedule, callback, cancel2).await });
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
cancel.cancel();
let result = handle.await.unwrap();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_misfire_does_not_fire_immediately() {
let schedule = make_schedule("0 0 1 1 *");
let counter = Arc::new(AtomicU64::new(0));
let cb_counter = counter.clone();
let callback: CronCallback = Arc::new(move |_fire| {
let c = cb_counter.clone();
Box::pin(async move {
c.fetch_add(1, Ordering::SeqCst);
Ok(())
})
});
let cancel = tokio_util::sync::CancellationToken::new();
let cancel2 = cancel.clone();
let handle =
tokio::spawn(async move { TokioCronService.run(&schedule, callback, cancel2).await });
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
cancel.cancel();
let _ = handle.await;
assert_eq!(
counter.load(Ordering::SeqCst),
0,
"should not fire for future schedule"
);
}
#[tokio::test]
async fn test_next_fire_computed_from_now() {
let cron: cron::Schedule = "0 * * * * *".parse().unwrap();
let now = chrono::Utc::now().with_timezone(&Tz::UTC);
let next = cron.upcoming(Tz::UTC).next().unwrap();
let delta = next - now;
assert!(
delta.num_seconds() >= 0 && delta.num_seconds() < 60,
"next fire should be within 60s, got {}s",
delta.num_seconds()
);
}
#[tokio::test]
async fn test_5_field_expression_parsed() {
for (unix_expr, normalized) in &[
("0 2 * * *", "0 0 2 * * *"),
("*/5 * * * *", "0 */5 * * * *"),
("0 9 * * 1", "0 0 9 * * 1"),
] {
let cron: cron::Schedule = normalized
.parse()
.unwrap_or_else(|e| panic!("'{}' (normalized '{}'): {}", unix_expr, normalized, e));
let next = cron.upcoming(Tz::UTC).next();
assert!(next.is_some(), "'{}' should have upcoming fires", unix_expr);
}
}
#[test]
fn test_dst_spring_forward_schedule_has_valid_next_fire() {
let cron: cron::Schedule = "0 0 0 * * *".parse().unwrap();
let tz = Tz::America__Sao_Paulo;
let next = cron.upcoming(tz).next();
assert!(
next.is_some(),
"spring-forward schedule must still have a next fire"
);
}
}