Skip to main content

camel_component_cron/
lib.rs

1//! cron component for rust-camel — fires `Exchange` events on Unix cron schedules.
2//!
3//! Main types: `CronComponent`, `CronConsumer`, `CronConfig`, `CronEndpoint`.
4//! URI format: `cron:name?schedule=0 2 * * *&timeZone=UTC`.
5//!
6//! # Features
7//!
8//! - **Unix 5-field cron**: `min hour dom month dow`
9//! - **Timezone-aware**: UTC default, configurable via `timeZone` param
10//! - **Misfire skip**: missed schedules during downtime are not replayed
11//! - **SPI-backed**: delegates scheduling to `CronService` (default: `TokioCronService`)
12
13mod tokio_impl;
14
15pub use tokio_impl::TokioCronService;
16
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19
20use tracing::debug;
21
22use camel_component_api::{
23    BoxProcessor, CamelError, Component, ComponentContext, Consumer, ConsumerContext, CronCallback,
24    CronFire, CronSchedule, CronService, Endpoint, ProducerContext, RuntimeObservability,
25    UriConfig,
26};
27use camel_component_api::{Exchange, Message};
28use chrono_tz::Tz;
29
30// ---------------------------------------------------------------------------
31// CronConfig
32// ---------------------------------------------------------------------------
33
34/// Configuration parsed from a cron URI.
35///
36/// Format: `cron:name?schedule=0 2 * * *&timeZone=UTC&includeMetadata=true`
37#[derive(Debug, Clone, UriConfig)]
38#[uri_scheme = "cron"]
39#[uri_config(skip_impl, crate = "camel_component_api")]
40pub struct CronConfig {
41    /// Cron trigger name (the path portion of the URI).
42    pub name: String,
43
44    /// 5-field Unix cron expression.
45    #[uri_param(name = "schedule")]
46    pub schedule: String,
47
48    /// IANA timezone. Default: UTC.
49    #[uri_param(name = "timeZone", default = "UTC")]
50    time_zone_str: String,
51
52    /// Whether to include cron metadata headers in exchanges.
53    #[uri_param(name = "includeMetadata", default = "true")]
54    include_metadata: bool,
55}
56
57impl CronConfig {
58    /// Parsed timezone.
59    pub fn time_zone(&self) -> Result<Tz, CamelError> {
60        self.time_zone_str.parse::<Tz>().map_err(|e| {
61            CamelError::EndpointCreationFailed(format!(
62                "invalid timeZone '{}': {}",
63                self.time_zone_str, e
64            ))
65        })
66    }
67
68    /// Whether metadata headers are included.
69    pub fn include_metadata(&self) -> bool {
70        self.include_metadata
71    }
72
73    /// Normalize a 5-field Unix cron expression to the `cron` crate's
74    /// 6-field format (prepend `0` for the seconds field).
75    ///
76    /// `"0 2 * * *"` → `"0 0 2 * * *"` (sec=0, min=0, hour=2)
77    fn normalized_cron_expression(&self) -> String {
78        format!("0 {}", self.schedule)
79    }
80
81    /// Validate: name non-empty, schedule is valid 5-field cron, timezone parseable.
82    pub fn validate(&self) -> Result<(), CamelError> {
83        if self.name.is_empty() {
84            return Err(CamelError::EndpointCreationFailed(
85                "cron name must not be empty".to_string(),
86            ));
87        }
88        if self.schedule.is_empty() {
89            return Err(CamelError::EndpointCreationFailed(
90                "cron schedule must not be empty".to_string(),
91            ));
92        }
93        // We restrict to 5-field Unix by counting fields.
94        let field_count = self.schedule.split_whitespace().count();
95        if field_count != 5 {
96            return Err(CamelError::EndpointCreationFailed(format!(
97                "cron schedule must be 5-field Unix format (min hour dom month dow), got {} field(s): '{}'",
98                field_count, self.schedule
99            )));
100        }
101        // The `cron` crate expects 6-7 fields (sec min hour dom month dow [year]).
102        // Normalize our 5-field Unix expression before parsing.
103        self.normalized_cron_expression()
104            .parse::<cron::Schedule>()
105            .map_err(|e| {
106                CamelError::EndpointCreationFailed(format!(
107                    "invalid cron expression '{}': {}",
108                    self.schedule, e
109                ))
110            })?;
111        // Validate timezone parses
112        self.time_zone()?;
113        Ok(())
114    }
115}
116
117impl UriConfig for CronConfig {
118    fn scheme() -> &'static str {
119        "cron"
120    }
121
122    fn from_uri(uri: &str) -> Result<Self, CamelError> {
123        let parts = camel_component_api::parse_uri(uri)?;
124        Self::from_components(parts)
125    }
126
127    fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
128        let mut config = Self::parse_uri_components(parts)?;
129        // Accept `+` as space in the schedule expression (Apache Camel convention).
130        // The global URI parser treats `+` as a literal plus; cron expressions
131        // never use `+`, so this component-local normalization is safe and
132        // greatly improves URI readability:
133        //   cron:t?schedule=0+2+*+*+*  instead of  cron:t?schedule=0%202%20*%20*%20*
134        config.schedule = config.schedule.replace('+', " ");
135        CronConfig::validate(&config)?;
136        Ok(config)
137    }
138
139    fn validate(self) -> Result<Self, CamelError> {
140        CronConfig::validate(&self)?;
141        Ok(self)
142    }
143}
144
145// ---------------------------------------------------------------------------
146// CronEndpoint
147// ---------------------------------------------------------------------------
148
149/// Endpoint for the `cron:` scheme.
150pub struct CronEndpoint {
151    uri: String,
152    config: CronConfig,
153    service: Arc<dyn CronService>,
154}
155
156impl CronEndpoint {
157    pub fn new(uri: String, config: CronConfig, service: Arc<dyn CronService>) -> Self {
158        Self {
159            uri,
160            config,
161            service,
162        }
163    }
164}
165
166impl Endpoint for CronEndpoint {
167    fn uri(&self) -> &str {
168        &self.uri
169    }
170
171    fn create_consumer(
172        &self,
173        _rt: Arc<dyn RuntimeObservability>,
174    ) -> Result<Box<dyn Consumer>, CamelError> {
175        Ok(Box::new(CronConsumer::new(
176            self.config.clone(),
177            self.service.clone(),
178        )))
179    }
180
181    fn create_producer(
182        &self,
183        _rt: Arc<dyn RuntimeObservability>,
184        _ctx: &ProducerContext,
185    ) -> Result<BoxProcessor, CamelError> {
186        Err(CamelError::EndpointCreationFailed(
187            "cron: component is consumer-only".to_string(),
188        ))
189    }
190}
191
192// ---------------------------------------------------------------------------
193// CronConsumer
194// ---------------------------------------------------------------------------
195
196/// Consumer that fires Exchanges on a cron schedule.
197pub struct CronConsumer {
198    config: CronConfig,
199    service: Arc<dyn CronService>,
200    started: AtomicBool,
201}
202
203impl CronConsumer {
204    /// Create a new CronConsumer with the given config and scheduling service.
205    pub fn new(config: CronConfig, service: Arc<dyn CronService>) -> Self {
206        Self {
207            config,
208            service,
209            started: AtomicBool::new(false),
210        }
211    }
212
213    /// Test helper: force the started flag to true.
214    #[cfg(test)]
215    pub(crate) fn mark_started_for_test(&self) {
216        self.started.store(true, Ordering::SeqCst);
217    }
218}
219
220#[async_trait::async_trait]
221impl Consumer for CronConsumer {
222    async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
223        self.started
224            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
225            .map_err(|_| {
226                CamelError::EndpointCreationFailed("cron consumer already started".to_string())
227            })?;
228
229        CronConfig::validate(&self.config)?;
230        let config = self.config.clone();
231
232        let schedule = CronSchedule {
233            expression: config.normalized_cron_expression(),
234            time_zone: config.time_zone()?,
235            trigger_id: config.name.clone(),
236            route_id: Some(context.route_id().to_string()),
237        };
238
239        let include_metadata = config.include_metadata();
240        let trigger_name = config.name.clone();
241        let trigger_schedule = config.schedule.clone();
242        let time_zone_str = config.time_zone_str.clone();
243
244        // Build the callback that submits Exchanges to the pipeline.
245        let ctx = context.clone();
246        let callback: CronCallback = Arc::new(move |fire: CronFire| {
247            let ctx = ctx.clone();
248            let name = trigger_name.clone();
249            let sched = trigger_schedule.clone();
250            let tz = time_zone_str.clone();
251            Box::pin(async move {
252                let mut exchange = Exchange::new(Message::default());
253                if include_metadata {
254                    exchange.input.set_header("CamelCronName", name);
255                    exchange.input.set_header("CamelCronSchedule", sched);
256                    exchange.input.set_header("CamelCronTimezone", tz);
257                    exchange
258                        .input
259                        .set_header("CamelCronScheduledTime", fire.scheduled_at.to_rfc3339());
260                    exchange
261                        .input
262                        .set_header("CamelCronFiredTime", fire.fired_at.to_rfc3339());
263                    exchange
264                        .input
265                        .set_header("CamelCronCounter", fire.counter.to_string());
266                }
267                ctx.send(exchange).await
268            })
269        });
270
271        let cancel_token = context.cancel_token();
272        let trigger_id = schedule.trigger_id.clone();
273
274        // Delegate to the CronService. On error, propagate to Route supervision.
275        let result = self.service.run(&schedule, callback, cancel_token).await;
276
277        self.started.store(false, Ordering::SeqCst);
278
279        debug!(trigger = trigger_id, "cron consumer stopped");
280        result
281    }
282
283    async fn stop(&mut self) -> Result<(), CamelError> {
284        // The cancel token (held by ConsumerContext) triggers service::run to return.
285        // started flag is reset in start() after run returns.
286        Ok(())
287    }
288}
289
290// ---------------------------------------------------------------------------
291// CronComponent
292// ---------------------------------------------------------------------------
293
294/// Component for the `cron:` scheme. Factory for `CronEndpoint`s.
295///
296/// By default uses `TokioCronService`. Inject a custom `CronService` via
297/// [`CronComponent::with_service`] for alternative backends.
298pub struct CronComponent {
299    service: Arc<dyn CronService>,
300}
301
302impl Default for CronComponent {
303    fn default() -> Self {
304        Self::new()
305    }
306}
307
308impl CronComponent {
309    /// Create with the default `TokioCronService`.
310    pub fn new() -> Self {
311        Self {
312            service: Arc::new(TokioCronService),
313        }
314    }
315
316    /// Create with a custom `CronService` implementation.
317    pub fn with_service(service: Arc<dyn CronService>) -> Self {
318        Self { service }
319    }
320}
321
322impl Component for CronComponent {
323    fn scheme(&self) -> &str {
324        "cron"
325    }
326
327    fn create_endpoint(
328        &self,
329        uri: &str,
330        _ctx: &dyn ComponentContext,
331    ) -> Result<Box<dyn Endpoint>, CamelError> {
332        let config = CronConfig::from_uri(uri)?;
333        Ok(Box::new(CronEndpoint::new(
334            uri.to_string(),
335            config,
336            self.service.clone(),
337        )))
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_cron_config_basic() {
347        let config = CronConfig::from_uri("cron:nightly?schedule=0%202%20*%20*%20*").unwrap();
348        assert_eq!(config.name, "nightly");
349        assert_eq!(config.schedule, "0 2 * * *");
350        assert_eq!(config.time_zone_str, "UTC");
351        assert!(config.include_metadata);
352    }
353
354    #[test]
355    fn test_cron_config_with_timezone() {
356        let config =
357            CronConfig::from_uri("cron:morning?schedule=0%209%20*%20*%201&timeZone=America/Lima")
358                .unwrap();
359        assert_eq!(config.name, "morning");
360        assert_eq!(config.time_zone().unwrap(), chrono_tz::Tz::America__Lima);
361    }
362
363    #[test]
364    fn test_cron_config_percent_encoded_spaces() {
365        // URI parser should decode %20 to space
366        let config = CronConfig::from_uri("cron:test?schedule=0%202%20*%20*%20*").unwrap();
367        assert_eq!(config.schedule, "0 2 * * *");
368        config.validate().unwrap();
369    }
370
371    #[test]
372    fn test_cron_config_plus_as_space() {
373        // Apache Camel convention: `+` as space separator in cron expressions
374        let config = CronConfig::from_uri("cron:test?schedule=0+2+*+*+*").unwrap();
375        assert_eq!(config.schedule, "0 2 * * *");
376        config.validate().unwrap();
377    }
378
379    #[test]
380    fn test_rejects_empty_name() {
381        let err = CronConfig::from_uri("cron:?schedule=0%202%20*%20*%20*").unwrap_err();
382        assert!(
383            err.to_string().contains("must not be empty"),
384            "unexpected error: {err}"
385        );
386    }
387
388    #[test]
389    fn test_rejects_missing_schedule() {
390        let result = CronConfig::from_uri("cron:test");
391        // schedule is required — should fail at URI parse or validate
392        assert!(result.is_err() || result.unwrap().validate().is_err());
393    }
394
395    #[test]
396    fn test_rejects_six_field_cron() {
397        let err = CronConfig::from_uri("cron:test?schedule=0%200%202%20*%20*%20*").unwrap_err();
398        assert!(err.to_string().contains("5-field"));
399    }
400
401    #[test]
402    fn test_rejects_invalid_cron() {
403        let err = CronConfig::from_uri("cron:test?schedule=99%2099%2099%2099%2099").unwrap_err();
404        assert!(
405            err.to_string().contains("invalid cron"),
406            "unexpected error: {err}"
407        );
408    }
409
410    #[test]
411    fn test_rejects_invalid_timezone() {
412        let err = CronConfig::from_uri("cron:test?schedule=0%202%20*%20*%20*&timeZone=NotAZone")
413            .unwrap_err();
414        assert!(
415            err.to_string().contains("invalid timeZone"),
416            "unexpected error: {err}"
417        );
418    }
419
420    #[test]
421    fn test_valid_expressions() {
422        // Each URI must encode a valid 5-field Unix cron expression.
423        // 1. "0 2 * * *"        → daily at 02:00
424        // 2. "*/5 * * * *"      → every 5 minutes
425        // 3. "0 9 * * 1"        → Mondays at 09:00
426        for expr in &[
427            "0%202%20*%20*%20*",
428            "*%2F5%20*%20*%20*%20*",
429            "0%209%20*%20*%201",
430        ] {
431            let config = CronConfig::from_uri(&format!("cron:t?schedule={}", expr)).unwrap();
432            config
433                .validate()
434                .unwrap_or_else(|e| panic!("'{}' should be valid: {}", expr, e));
435        }
436    }
437
438    // --- Mock CronService for consumer tests (fires every 10ms) ---
439
440    struct FastCronService;
441    #[async_trait::async_trait]
442    impl CronService for FastCronService {
443        async fn run(
444            &self,
445            _schedule: &CronSchedule,
446            callback: CronCallback,
447            cancel: tokio_util::sync::CancellationToken,
448        ) -> Result<(), CamelError> {
449            let mut counter = 0u64;
450            loop {
451                tokio::select! {
452                    _ = cancel.cancelled() => return Ok(()),
453                    _ = tokio::time::sleep(tokio::time::Duration::from_millis(10)) => {
454                        counter += 1;
455                        let now = chrono::Utc::now();
456                        let fire = CronFire {
457                            scheduled_at: now,
458                            fired_at: now,
459                            counter,
460                        };
461                        callback(fire).await?;
462                    }
463                }
464            }
465        }
466    }
467
468    // --- CronConsumer tests ---
469
470    #[tokio::test]
471    async fn test_double_start_returns_error() {
472        let config = CronConfig::from_uri("cron:test?schedule=0 2 * * *").unwrap();
473        let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
474        consumer.mark_started_for_test();
475
476        let (tx, _rx) = tokio::sync::mpsc::channel(16);
477        let cancel = tokio_util::sync::CancellationToken::new();
478        let ctx = ConsumerContext::new(tx, cancel, "test-route".to_string());
479        let result = consumer.start(ctx).await;
480        assert!(result.is_err(), "double-start should error");
481    }
482
483    #[tokio::test]
484    async fn test_consumer_fires_exchange() {
485        let config = CronConfig::from_uri("cron:test?schedule=* * * * *").unwrap();
486        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
487        let cancel = tokio_util::sync::CancellationToken::new();
488        let ctx = ConsumerContext::new(tx, cancel.clone(), "test-route".to_string());
489        let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
490
491        let cancel2 = cancel.clone();
492        let handle = tokio::spawn(async move { consumer.start(ctx).await });
493
494        // FastCronService fires every 10ms — should get an exchange quickly
495        let exchange = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv()).await;
496        assert!(exchange.is_ok(), "should have received an exchange");
497
498        cancel2.cancel();
499        let _ = handle.await;
500    }
501
502    #[tokio::test]
503    async fn test_consumer_metadata_headers() {
504        let config = CronConfig::from_uri("cron:test?schedule=* * * * *").unwrap();
505        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
506        let cancel = tokio_util::sync::CancellationToken::new();
507        let ctx = ConsumerContext::new(tx, cancel.clone(), "test-route".to_string());
508        let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
509
510        let cancel2 = cancel.clone();
511        tokio::spawn(async move { consumer.start(ctx).await });
512
513        let envelope = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
514            .await
515            .unwrap()
516            .unwrap();
517
518        let headers = &envelope.exchange.input.headers;
519        assert!(headers.contains_key("CamelCronName"));
520        assert!(headers.contains_key("CamelCronSchedule"));
521        assert!(headers.contains_key("CamelCronTimezone"));
522        assert!(headers.contains_key("CamelCronScheduledTime"));
523        assert!(headers.contains_key("CamelCronFiredTime"));
524        assert!(headers.contains_key("CamelCronCounter"));
525
526        cancel2.cancel();
527    }
528
529    #[tokio::test]
530    async fn test_include_metadata_false_omits_headers() {
531        let config =
532            CronConfig::from_uri("cron:test?schedule=* * * * *&includeMetadata=false").unwrap();
533        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
534        let cancel = tokio_util::sync::CancellationToken::new();
535        let ctx = ConsumerContext::new(tx, cancel.clone(), "test-route".to_string());
536        let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
537
538        let cancel2 = cancel.clone();
539        tokio::spawn(async move { consumer.start(ctx).await });
540
541        let envelope = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
542            .await
543            .unwrap()
544            .unwrap();
545
546        assert!(
547            envelope.exchange.input.headers.is_empty(),
548            "no headers when includeMetadata=false"
549        );
550
551        cancel2.cancel();
552    }
553
554    #[tokio::test]
555    async fn test_callback_error_propagates_to_start() {
556        // If send fails (channel closed), start() should return Err.
557        let config = CronConfig::from_uri("cron:test?schedule=* * * * *").unwrap();
558        let (tx, rx) = tokio::sync::mpsc::channel(16);
559        // Drop receiver immediately so send fails
560        drop(rx);
561        let cancel = tokio_util::sync::CancellationToken::new();
562        let ctx = ConsumerContext::new(tx, cancel, "test-route".to_string());
563        let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
564
565        let result =
566            tokio::time::timeout(tokio::time::Duration::from_secs(2), consumer.start(ctx)).await;
567
568        // Should complete (not timeout) with an error
569        let inner = result.expect("should not timeout");
570        assert!(
571            inner.is_err(),
572            "closed channel should propagate error to start()"
573        );
574    }
575
576    // --- CronComponent tests ---
577
578    #[test]
579    fn test_cron_component_scheme() {
580        let comp = CronComponent::new();
581        assert_eq!(comp.scheme(), "cron");
582    }
583
584    #[test]
585    fn test_cron_component_creates_endpoint() {
586        let comp = CronComponent::new();
587        let ctx = camel_component_api::NoOpComponentContext;
588        let endpoint = comp
589            .create_endpoint("cron:nightly?schedule=0 2 * * *", &ctx)
590            .unwrap();
591        assert_eq!(endpoint.uri(), "cron:nightly?schedule=0 2 * * *");
592    }
593
594    #[test]
595    fn test_cron_component_rejects_invalid_schedule() {
596        let comp = CronComponent::new();
597        let ctx = camel_component_api::NoOpComponentContext;
598        let result = comp.create_endpoint("cron:test?schedule=invalid", &ctx);
599        assert!(result.is_err());
600    }
601
602    #[test]
603    fn test_cron_component_with_custom_service() {
604        let custom_service: Arc<dyn CronService> = Arc::new(TokioCronService);
605        let comp = CronComponent::with_service(custom_service);
606        assert_eq!(comp.scheme(), "cron");
607    }
608}