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