1use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::Duration;
14
15use async_trait::async_trait;
16use chrono::Utc;
17use tokio::time;
18use tracing::debug;
19
20use camel_component_api::{BoxProcessor, CamelError, Exchange, Message};
21use camel_component_api::{Component, Consumer, ConsumerContext, Endpoint, ProducerContext};
22use camel_component_api::{ComponentMetadata, UriConfig};
23
24#[derive(Debug, Clone, UriConfig)]
32#[uri_scheme = "timer"]
33#[uri_config(
34 skip_impl,
35 metadata(
36 scheme = "timer",
37 description = "Generate timer-based events",
38 consumer
39 ),
40 crate = "camel_component_api"
41)]
42pub struct TimerConfig {
43 pub name: String,
45
46 #[allow(dead_code)] #[uri_param(name = "period", default = "1000")]
49 period_ms: u64,
50
51 pub period: Duration,
53
54 #[allow(dead_code)] #[uri_param(name = "delay", default = "0")]
57 delay_ms: u64,
58
59 pub delay: Duration,
61
62 #[uri_param(name = "repeatCount")]
64 pub repeat_count: Option<u32>,
65
66 #[uri_param(name = "fixedRate", default = "false")]
69 pub fixed_rate: bool,
70
71 #[uri_param(name = "includeMetadata", default = "true")]
75 pub include_metadata: bool,
76}
77
78impl TimerConfig {
80 pub fn validate(&self) -> Result<(), CamelError> {
82 if self.name.trim().is_empty() {
83 return Err(CamelError::InvalidUri(
84 "timer name must not be empty".to_string(),
85 ));
86 }
87 if self.period.is_zero() {
88 return Err(CamelError::InvalidUri(
89 "timer period must be greater than 0".to_string(),
90 ));
91 }
92 Ok(())
93 }
94}
95
96impl UriConfig for TimerConfig {
97 fn scheme() -> &'static str {
98 "timer"
99 }
100
101 fn from_uri(uri: &str) -> Result<Self, CamelError> {
102 let parts = camel_component_api::parse_uri(uri)?;
103 Self::from_components(parts)
104 }
105
106 fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
107 let config = Self::parse_uri_components(parts)?;
108 TimerConfig::validate(&config)?;
109 Ok(config)
110 }
111
112 fn validate(self) -> Result<Self, CamelError> {
113 TimerConfig::validate(&self)?;
115 Ok(self)
116 }
117}
118
119pub struct TimerComponent;
125
126impl TimerComponent {
127 pub fn new() -> Self {
128 Self
129 }
130}
131
132impl Default for TimerComponent {
133 fn default() -> Self {
134 Self::new()
135 }
136}
137
138impl Component for TimerComponent {
139 fn scheme(&self) -> &str {
140 "timer"
141 }
142
143 fn metadata(&self) -> ComponentMetadata {
144 TimerConfig::metadata()
145 }
146
147 fn create_endpoint(
148 &self,
149 uri: &str,
150 _ctx: &dyn camel_component_api::ComponentContext,
151 ) -> Result<Box<dyn Endpoint>, CamelError> {
152 let config = TimerConfig::from_uri(uri)?;
153 Ok(Box::new(TimerEndpoint {
154 uri: uri.to_string(),
155 config,
156 }))
157 }
158}
159
160pub struct TimerEndpoint {
165 uri: String,
166 config: TimerConfig,
167}
168
169impl Endpoint for TimerEndpoint {
170 fn uri(&self) -> &str {
171 &self.uri
172 }
173
174 fn create_consumer(
175 &self,
176 rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
177 ) -> Result<Box<dyn Consumer>, CamelError> {
178 Ok(Box::new(TimerConsumer {
179 config: self.config.clone(),
180 started: AtomicBool::new(false),
181 runtime: rt,
182 }))
183 }
184
185 fn create_producer(
186 &self,
187 _rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
188 _ctx: &ProducerContext,
189 ) -> Result<BoxProcessor, CamelError> {
190 Err(CamelError::EndpointCreationFailed(
191 "timer endpoint does not support producers".to_string(),
192 ))
193 }
194}
195
196pub struct TimerConsumer {
201 config: TimerConfig,
202 started: AtomicBool,
204 runtime: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
207}
208
209#[async_trait]
210impl Consumer for TimerConsumer {
211 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
212 self.started
214 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
215 .map_err(|_| {
216 CamelError::EndpointCreationFailed("timer consumer already started".to_string())
217 })?;
218
219 TimerConfig::validate(&self.config)?;
220 let config = self.config.clone();
221 let cancel_token = context.cancel_token();
222
223 if !config.delay.is_zero() {
225 tokio::select! {
226 _ = time::sleep(config.delay) => {}
227 _ = cancel_token.cancelled() => {
228 debug!(timer = config.name, "Timer cancelled during initial delay");
229 self.started.store(false, Ordering::SeqCst);
230 return Ok(());
231 }
232 }
233 }
234
235 if config.repeat_count == Some(0) {
237 debug!(timer = config.name, "repeat_count=0, timer will not fire");
238 self.started.store(false, Ordering::SeqCst);
239 return Ok(());
240 }
241
242 let mut interval = time::interval(config.period);
243
244 if config.fixed_rate {
246 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
247 } else {
248 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Burst);
249 }
250
251 let mut count: u32 = 0;
252
253 loop {
254 tokio::select! {
255 _ = cancel_token.cancelled() => {
256 debug!(timer = config.name, "Timer received cancellation, stopping");
257 break;
258 }
259 _ = interval.tick() => {
260 count += 1;
261
262 debug!(timer = config.name, count, "Timer tick");
263
264 let mut exchange = Exchange::new(Message::new(format!(
265 "timer://{} tick #{}",
266 config.name, count
267 )));
268
269 if config.include_metadata {
271 exchange.input.set_header(
272 "CamelTimerName",
273 serde_json::Value::String(config.name.clone()),
274 );
275 exchange
276 .input
277 .set_header("CamelTimerCounter", serde_json::Value::Number(count.into()));
278
279 let now = Utc::now();
281 exchange.input.set_header(
282 "CamelTimerFiredTime",
283 serde_json::Value::String(now.to_rfc3339()),
284 );
285 exchange.input.set_header(
286 "CamelMessageTimestamp",
287 serde_json::Value::Number(
288 now.timestamp_millis().into(),
289 ),
290 );
291 }
292
293 if context.send(exchange).await.is_err() {
294 self.runtime
298 .metrics()
299 .increment_errors(context.route_id(), "b-prime:timer:fire-send");
300 break;
302 }
303
304 if let Some(max) = config.repeat_count
305 && count >= max
306 {
307 break;
308 }
309 }
310 }
311 }
312
313 self.started.store(false, Ordering::SeqCst);
315 Ok(())
316 }
317
318 async fn stop(&mut self) -> Result<(), CamelError> {
319 self.started.store(false, Ordering::SeqCst);
320 debug!(timer = self.config.name, "timer consumer stopped");
321 Ok(())
322 }
323}
324
325impl TimerConsumer {
326 #[cfg(test)]
328 pub(crate) fn mark_started_for_test(&self) {
329 self.started.store(true, Ordering::SeqCst);
330 }
331}
332
333#[cfg(test)]
338mod tests {
339 use std::sync::{Arc, Mutex};
340
341 use camel_component_api::test_support::PanicRuntimeObservability;
342 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
343 std::sync::Arc::new(PanicRuntimeObservability)
344 }
345
346 use super::*;
347 use camel_component_api::NoOpComponentContext;
348
349 #[test]
350 fn test_zero_period_rejected() {
351 let result = TimerConfig::from_uri("timer:tick?period=0");
352 assert!(result.is_err(), "period=0 should be rejected");
353 let err_msg = result.unwrap_err().to_string();
354 assert!(err_msg.contains("period"), "error should mention 'period'");
355 }
356
357 #[test]
358 fn test_timer_empty_name_rejected() {
359 let result = TimerConfig::from_uri("timer:");
360 assert!(result.is_err());
361 let err = result.unwrap_err().to_string();
362 assert!(err.contains("must not be empty"), "unexpected error: {err}");
363 }
364
365 #[test]
366 fn test_timer_config_defaults() {
367 let config = TimerConfig::from_uri("timer:tick").unwrap();
368 assert_eq!(config.name, "tick");
369 assert_eq!(config.period, Duration::from_millis(1000));
370 assert_eq!(config.delay, Duration::from_millis(0));
371 assert_eq!(config.repeat_count, None);
372 }
373
374 #[test]
375 fn test_timer_config_with_params() {
376 let config =
377 TimerConfig::from_uri("timer:myTimer?period=500&delay=100&repeatCount=5").unwrap();
378 assert_eq!(config.name, "myTimer");
379 assert_eq!(config.period, Duration::from_millis(500));
380 assert_eq!(config.delay, Duration::from_millis(100));
381 assert_eq!(config.repeat_count, Some(5));
382 }
383
384 #[test]
385 fn test_timer_config_wrong_scheme() {
386 let result = TimerConfig::from_uri("log:info");
387 assert!(result.is_err());
388 }
389
390 #[test]
391 fn test_timer_component_scheme() {
392 let component = TimerComponent::new();
393 assert_eq!(component.scheme(), "timer");
394 }
395
396 #[test]
397 fn test_timer_component_creates_endpoint() {
398 let component = TimerComponent::new();
399 let endpoint = component.create_endpoint("timer:tick?period=1000", &NoOpComponentContext);
400 assert!(endpoint.is_ok());
401 }
402
403 #[test]
404 fn test_timer_endpoint_no_producer() {
405 let ctx = ProducerContext::new();
406 let component = TimerComponent::new();
407 let endpoint = component
408 .create_endpoint("timer:tick", &NoOpComponentContext)
409 .unwrap();
410 let producer = endpoint.create_producer(rt(), &ctx);
411 assert!(producer.is_err());
412 }
413
414 #[test]
415 fn test_rejects_empty_timer_name() {
416 let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
417 cfg.name = "".into();
418 assert!(cfg.validate().is_err());
419 }
420
421 #[test]
422 fn test_rejects_zero_period() {
423 let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
424 cfg.period = Duration::ZERO;
425 assert!(cfg.validate().is_err());
426 }
427
428 #[test]
429 fn test_valid_config_passes() {
430 let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
431 cfg.name = "myTimer".into();
432 cfg.period = Duration::from_millis(1000);
433 assert!(cfg.validate().is_ok());
434 }
435
436 #[tokio::test]
437 async fn test_repeat_count_zero_fires_never() {
438 let component = TimerComponent::new();
439 let endpoint = component
440 .create_endpoint(
441 "timer:zero-test?period=50&repeatCount=0",
442 &NoOpComponentContext,
443 )
444 .unwrap();
445 let mut consumer = endpoint.create_consumer(rt()).unwrap();
446
447 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
448 let ctx = ConsumerContext::new(
449 tx,
450 tokio_util::sync::CancellationToken::new(),
451 "timer-test-route".to_string(),
452 );
453
454 consumer.start(ctx).await.unwrap();
456
457 tokio::time::sleep(Duration::from_millis(200)).await;
459
460 let mut count = 0;
462 while rx.try_recv().is_ok() {
463 count += 1;
464 }
465 assert_eq!(
466 count, 0,
467 "repeat_count=0 should produce zero fires, got {count}"
468 );
469
470 consumer.stop().await.unwrap();
472 }
473
474 #[tokio::test]
475 async fn test_repeat_count_omitted_fires_indefinitely() {
476 use tokio_util::sync::CancellationToken;
477
478 let token = CancellationToken::new();
479 let (tx, mut rx) = tokio::sync::mpsc::channel(32);
480 let ctx = ConsumerContext::new(tx, token.clone(), "timer-infinite-route".to_string());
481
482 let mut consumer = TimerConsumer {
484 config: TimerConfig::from_uri("timer:infinite-test?period=20").unwrap(),
485 started: AtomicBool::new(false),
486 runtime: rt(),
487 };
488
489 let handle = tokio::spawn(async move {
490 consumer.start(ctx).await.unwrap();
491 });
492
493 tokio::time::sleep(Duration::from_millis(200)).await;
496 token.cancel();
497 let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
498
499 let mut count = 0;
500 while rx.try_recv().is_ok() {
501 count += 1;
502 }
503 assert!(
504 count >= 6,
505 "Omitted repeatCount should fire indefinitely; expected >= 6 fires in window, got {count}"
506 );
507 }
508
509 #[tokio::test]
510 async fn test_timer_consumer_fires() {
511 let component = TimerComponent::new();
512 let endpoint = component
513 .create_endpoint("timer:test?period=50&repeatCount=3", &NoOpComponentContext)
514 .unwrap();
515 let mut consumer = endpoint.create_consumer(rt()).unwrap();
516
517 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
518 let ctx = ConsumerContext::new(
519 tx,
520 tokio_util::sync::CancellationToken::new(),
521 "timer-test-route".to_string(),
522 );
523
524 tokio::spawn(async move {
526 consumer.start(ctx).await.unwrap();
527 });
528
529 let mut received = Vec::new();
531 loop {
532 match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
533 Ok(Some(envelope)) => {
534 received.push(envelope.exchange);
535 if received.len() == 3 {
536 break;
537 }
538 }
539 Ok(None) => break,
540 Err(_) => panic!("timer rx drain stalled past 2s"),
541 }
542 }
543
544 assert_eq!(received.len(), 3);
545
546 let first = &received[0];
548 assert_eq!(
549 first.input.header("CamelTimerName"),
550 Some(&serde_json::Value::String("test".into()))
551 );
552 assert_eq!(
553 first.input.header("CamelTimerCounter"),
554 Some(&serde_json::Value::Number(1.into()))
555 );
556 }
557
558 #[tokio::test]
559 async fn test_timer_consumer_respects_cancellation() {
560 use tokio_util::sync::CancellationToken;
561
562 let token = CancellationToken::new();
563 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
564 let ctx = ConsumerContext::new(tx, token.clone(), "timer-test-route".to_string());
565
566 let mut consumer = TimerConsumer {
567 config: TimerConfig::from_uri("timer:cancel-test?period=50").unwrap(),
568 started: AtomicBool::new(false),
569 runtime: rt(),
570 };
571
572 let handle = tokio::spawn(async move {
573 consumer.start(ctx).await.unwrap();
574 });
575
576 tokio::time::sleep(Duration::from_millis(180)).await;
578 token.cancel();
579
580 let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
581 assert!(
582 result.is_ok(),
583 "Consumer should have stopped after cancellation"
584 );
585
586 let mut count = 0;
587 while rx.try_recv().is_ok() {
588 count += 1;
589 }
590 assert!(
591 count >= 2,
592 "Expected at least 2 exchanges before cancellation, got {count}"
593 );
594 }
595
596 #[tokio::test]
597 async fn test_timer_consumer_stop_shuts_down() {
598 let component = TimerComponent::new();
599 let endpoint = component
600 .create_endpoint("timer:stop-test?period=50", &NoOpComponentContext)
601 .unwrap();
602 let mut consumer = endpoint.create_consumer(rt()).unwrap();
603
604 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
605 let token = tokio_util::sync::CancellationToken::new();
606 let ctx = ConsumerContext::new(tx, token.clone(), "timer-test-route".to_string());
607
608 tokio::spawn(async move {
610 consumer.start(ctx).await.unwrap();
611 });
612
613 tokio::time::sleep(Duration::from_millis(180)).await;
615
616 let mut count = 0;
618 while rx.try_recv().is_ok() {
619 count += 1;
620 }
621 assert!(count >= 2, "Expected at least 2 exchanges, got {count}");
622
623 token.cancel();
625 }
626
627 #[test]
629 fn test_fixed_rate_default_is_false() {
630 let config = TimerConfig::from_uri("timer:tick").unwrap();
631 assert!(!config.fixed_rate, "fixedRate should default to false");
632 }
633
634 #[test]
635 fn test_fixed_rate_parsed_from_uri() {
636 let config = TimerConfig::from_uri("timer:tick?fixedRate=true").unwrap();
637 assert!(
638 config.fixed_rate,
639 "fixedRate should be true when set in URI"
640 );
641 }
642
643 #[tokio::test]
645 async fn test_double_start_returns_error() {
646 let component = TimerComponent::new();
647 let endpoint = component
648 .create_endpoint(
649 "timer:double?period=50&repeatCount=2",
650 &NoOpComponentContext,
651 )
652 .unwrap(); let mut consumer = TimerConsumer {
655 config: TimerConfig {
656 name: "double-test".to_string(),
657 period: Duration::from_millis(100),
658 period_ms: 100,
659 delay: Duration::ZERO,
660 delay_ms: 0,
661 repeat_count: None,
662 fixed_rate: false,
663 include_metadata: true,
664 },
665 started: AtomicBool::new(false),
666 runtime: rt(),
667 };
668
669 consumer.mark_started_for_test();
671
672 let (tx, _rx) = tokio::sync::mpsc::channel(16);
673 let cancel_token = tokio_util::sync::CancellationToken::new();
674 let ctx = ConsumerContext::new(tx, cancel_token.clone(), "timer-test-route".to_string());
675
676 let result = consumer.start(ctx).await;
678 assert!(result.is_err(), "expected double-start to return Err");
679 let err_str = format!("{:?}", result.unwrap_err());
680 assert!(
681 err_str.contains("already started"),
682 "unexpected error: {err_str}"
683 );
684
685 drop(endpoint); }
687
688 #[tokio::test]
690 async fn test_timer_fired_time_and_message_timestamp_headers() {
691 let component = TimerComponent::new();
692 let endpoint = component
693 .create_endpoint(
694 "timer:headers?period=50&repeatCount=1",
695 &NoOpComponentContext,
696 )
697 .unwrap();
698 let mut consumer = endpoint.create_consumer(rt()).unwrap();
699
700 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
701 let ctx = ConsumerContext::new(
702 tx,
703 tokio_util::sync::CancellationToken::new(),
704 "timer-test-route".to_string(),
705 );
706
707 tokio::spawn(async move {
708 consumer.start(ctx).await.unwrap();
709 });
710
711 let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
712 .await
713 .expect("should receive exchange")
714 .expect("envelope should exist");
715
716 let exchange = envelope.exchange;
717
718 let fired_time = exchange
720 .input
721 .header("CamelTimerFiredTime")
722 .expect("CamelTimerFiredTime header should be present");
723 assert!(
724 fired_time.is_string(),
725 "CamelTimerFiredTime should be a string"
726 );
727 let fired_str = fired_time.as_str().unwrap();
728 assert!(
730 chrono::DateTime::parse_from_rfc3339(fired_str).is_ok(),
731 "CamelTimerFiredTime should be valid RFC 3339: {fired_str}"
732 );
733
734 let msg_ts = exchange
736 .input
737 .header("CamelMessageTimestamp")
738 .expect("CamelMessageTimestamp header should be present");
739 assert!(
740 msg_ts.is_number(),
741 "CamelMessageTimestamp should be a number"
742 );
743 let ts_millis = msg_ts.as_i64().expect("should be i64");
744 assert!(ts_millis > 0, "timestamp should be positive");
745 }
746
747 #[test]
748 fn test_timer_fired_time_header_format() {
749 let now = chrono::Utc::now();
751 let rfc = now.to_rfc3339();
752 assert!(chrono::DateTime::parse_from_rfc3339(&rfc).is_ok());
753 let millis = now.timestamp_millis();
754 assert!(millis > 0);
755 }
756
757 #[test]
759 fn test_include_metadata_default_is_true() {
760 let config = TimerConfig::from_uri("timer:tick").unwrap();
761 assert!(
762 config.include_metadata,
763 "includeMetadata should default to true"
764 );
765 }
766
767 #[test]
768 fn test_include_metadata_false_from_uri() {
769 let config = TimerConfig::from_uri("timer:tick?includeMetadata=false").unwrap();
770 assert!(
771 !config.include_metadata,
772 "includeMetadata should be false when set in URI"
773 );
774 }
775
776 #[tokio::test]
777 async fn test_include_metadata_false_omits_headers() {
778 let component = TimerComponent::new();
779 let endpoint = component
780 .create_endpoint(
781 "timer:minimal?period=50&repeatCount=1&includeMetadata=false",
782 &NoOpComponentContext,
783 )
784 .unwrap();
785 let mut consumer = endpoint.create_consumer(rt()).unwrap();
786
787 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
788 let ctx = ConsumerContext::new(
789 tx,
790 tokio_util::sync::CancellationToken::new(),
791 "timer-test-route".to_string(),
792 );
793
794 tokio::spawn(async move {
795 consumer.start(ctx).await.unwrap();
796 });
797
798 let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
799 .await
800 .expect("should receive exchange")
801 .expect("envelope should exist");
802
803 let exchange = envelope.exchange;
804
805 assert!(
807 exchange.input.header("CamelTimerName").is_none(),
808 "CamelTimerName should not be present when includeMetadata=false"
809 );
810 assert!(
811 exchange.input.header("CamelTimerCounter").is_none(),
812 "CamelTimerCounter should not be present when includeMetadata=false"
813 );
814 assert!(
815 exchange.input.header("CamelTimerFiredTime").is_none(),
816 "CamelTimerFiredTime should not be present when includeMetadata=false"
817 );
818 assert!(
819 exchange.input.header("CamelMessageTimestamp").is_none(),
820 "CamelMessageTimestamp should not be present when includeMetadata=false"
821 );
822 }
823
824 #[tokio::test]
825 async fn test_include_metadata_true_includes_all_headers() {
826 let component = TimerComponent::new();
827 let endpoint = component
828 .create_endpoint(
829 "timer:full?period=50&repeatCount=1&includeMetadata=true",
830 &NoOpComponentContext,
831 )
832 .unwrap();
833 let mut consumer = endpoint.create_consumer(rt()).unwrap();
834
835 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
836 let ctx = ConsumerContext::new(
837 tx,
838 tokio_util::sync::CancellationToken::new(),
839 "timer-test-route".to_string(),
840 );
841
842 tokio::spawn(async move {
843 consumer.start(ctx).await.unwrap();
844 });
845
846 let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
847 .await
848 .expect("should receive exchange")
849 .expect("envelope should exist");
850
851 let exchange = envelope.exchange;
852
853 assert!(exchange.input.header("CamelTimerName").is_some());
854 assert!(exchange.input.header("CamelTimerCounter").is_some());
855 assert!(exchange.input.header("CamelTimerFiredTime").is_some());
856 assert!(exchange.input.header("CamelMessageTimestamp").is_some());
857 }
858
859 #[test]
861 fn test_timer_endpoint_is_pub() {
862 let component = TimerComponent::new();
863 let endpoint = component
864 .create_endpoint("timer:pub-test", &NoOpComponentContext)
865 .unwrap();
866 assert_eq!(endpoint.uri(), "timer:pub-test");
867 }
868
869 struct RecordingMetrics {
875 errors: Arc<Mutex<Vec<(String, String)>>>,
876 }
877
878 impl camel_api::MetricsCollector for RecordingMetrics {
879 fn record_exchange_duration(&self, _: &str, _: Duration) {}
880 fn increment_errors(&self, route_id: &str, error_type: &str) {
881 self.errors
882 .lock()
883 .unwrap()
884 .push((route_id.to_string(), error_type.to_string()));
885 }
886 fn increment_exchanges(&self, _: &str) {}
887 fn set_queue_depth(&self, _: &str, _: usize) {}
888 fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
889 }
890
891 struct RecordingRuntime {
892 metrics_collector: Arc<RecordingMetrics>,
893 }
894
895 impl RecordingRuntime {
896 fn new(errors: Arc<Mutex<Vec<(String, String)>>>) -> Self {
897 Self {
898 metrics_collector: Arc::new(RecordingMetrics { errors }),
899 }
900 }
901 }
902
903 impl camel_component_api::RuntimeObservability for RecordingRuntime {
904 fn metrics(&self) -> Arc<dyn camel_api::MetricsCollector> {
905 Arc::clone(&self.metrics_collector) as Arc<dyn camel_api::MetricsCollector>
906 }
907 fn health(&self) -> Arc<dyn camel_component_api::HealthCheckRegistry> {
908 panic!("RecordingRuntime::health not used in this test")
909 }
910 }
911
912 #[tokio::test]
913 async fn timer_fire_send_failure_counts_b_prime() {
914 let errors = Arc::new(Mutex::new(Vec::new()));
915 let component = TimerComponent::new();
916 let endpoint = component
917 .create_endpoint("timer:fire-send?period=20", &NoOpComponentContext)
918 .unwrap();
919
920 let mut consumer = endpoint
923 .create_consumer(Arc::new(RecordingRuntime::new(Arc::clone(&errors))))
924 .unwrap();
925 let (tx, _) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
927 let ctx = ConsumerContext::new(
928 tx,
929 tokio_util::sync::CancellationToken::new(),
930 "timer-test-route".to_string(),
931 );
932
933 consumer.start(ctx).await.unwrap();
935
936 let recorded = errors.lock().unwrap().clone();
937 assert_eq!(
938 recorded,
939 vec![(
940 "timer-test-route".to_string(),
941 "b-prime:timer:fire-send".to_string()
942 )]
943 );
944
945 consumer.stop().await.unwrap();
946 }
947}