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 while let Some(envelope) = rx.recv().await {
532 received.push(envelope.exchange);
533 if received.len() == 3 {
534 break;
535 }
536 }
537
538 assert_eq!(received.len(), 3);
539
540 let first = &received[0];
542 assert_eq!(
543 first.input.header("CamelTimerName"),
544 Some(&serde_json::Value::String("test".into()))
545 );
546 assert_eq!(
547 first.input.header("CamelTimerCounter"),
548 Some(&serde_json::Value::Number(1.into()))
549 );
550 }
551
552 #[tokio::test]
553 async fn test_timer_consumer_respects_cancellation() {
554 use tokio_util::sync::CancellationToken;
555
556 let token = CancellationToken::new();
557 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
558 let ctx = ConsumerContext::new(tx, token.clone(), "timer-test-route".to_string());
559
560 let mut consumer = TimerConsumer {
561 config: TimerConfig::from_uri("timer:cancel-test?period=50").unwrap(),
562 started: AtomicBool::new(false),
563 runtime: rt(),
564 };
565
566 let handle = tokio::spawn(async move {
567 consumer.start(ctx).await.unwrap();
568 });
569
570 tokio::time::sleep(Duration::from_millis(180)).await;
572 token.cancel();
573
574 let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
575 assert!(
576 result.is_ok(),
577 "Consumer should have stopped after cancellation"
578 );
579
580 let mut count = 0;
581 while rx.try_recv().is_ok() {
582 count += 1;
583 }
584 assert!(
585 count >= 2,
586 "Expected at least 2 exchanges before cancellation, got {count}"
587 );
588 }
589
590 #[tokio::test]
591 async fn test_timer_consumer_stop_shuts_down() {
592 let component = TimerComponent::new();
593 let endpoint = component
594 .create_endpoint("timer:stop-test?period=50", &NoOpComponentContext)
595 .unwrap();
596 let mut consumer = endpoint.create_consumer(rt()).unwrap();
597
598 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
599 let token = tokio_util::sync::CancellationToken::new();
600 let ctx = ConsumerContext::new(tx, token.clone(), "timer-test-route".to_string());
601
602 tokio::spawn(async move {
604 consumer.start(ctx).await.unwrap();
605 });
606
607 tokio::time::sleep(Duration::from_millis(180)).await;
609
610 let mut count = 0;
612 while rx.try_recv().is_ok() {
613 count += 1;
614 }
615 assert!(count >= 2, "Expected at least 2 exchanges, got {count}");
616
617 token.cancel();
619 }
620
621 #[test]
623 fn test_fixed_rate_default_is_false() {
624 let config = TimerConfig::from_uri("timer:tick").unwrap();
625 assert!(!config.fixed_rate, "fixedRate should default to false");
626 }
627
628 #[test]
629 fn test_fixed_rate_parsed_from_uri() {
630 let config = TimerConfig::from_uri("timer:tick?fixedRate=true").unwrap();
631 assert!(
632 config.fixed_rate,
633 "fixedRate should be true when set in URI"
634 );
635 }
636
637 #[tokio::test]
639 async fn test_double_start_returns_error() {
640 let component = TimerComponent::new();
641 let endpoint = component
642 .create_endpoint(
643 "timer:double?period=50&repeatCount=2",
644 &NoOpComponentContext,
645 )
646 .unwrap(); let mut consumer = TimerConsumer {
649 config: TimerConfig {
650 name: "double-test".to_string(),
651 period: Duration::from_millis(100),
652 period_ms: 100,
653 delay: Duration::ZERO,
654 delay_ms: 0,
655 repeat_count: None,
656 fixed_rate: false,
657 include_metadata: true,
658 },
659 started: AtomicBool::new(false),
660 runtime: rt(),
661 };
662
663 consumer.mark_started_for_test();
665
666 let (tx, _rx) = tokio::sync::mpsc::channel(16);
667 let cancel_token = tokio_util::sync::CancellationToken::new();
668 let ctx = ConsumerContext::new(tx, cancel_token.clone(), "timer-test-route".to_string());
669
670 let result = consumer.start(ctx).await;
672 assert!(result.is_err(), "expected double-start to return Err");
673 let err_str = format!("{:?}", result.unwrap_err());
674 assert!(
675 err_str.contains("already started"),
676 "unexpected error: {err_str}"
677 );
678
679 drop(endpoint); }
681
682 #[tokio::test]
684 async fn test_timer_fired_time_and_message_timestamp_headers() {
685 let component = TimerComponent::new();
686 let endpoint = component
687 .create_endpoint(
688 "timer:headers?period=50&repeatCount=1",
689 &NoOpComponentContext,
690 )
691 .unwrap();
692 let mut consumer = endpoint.create_consumer(rt()).unwrap();
693
694 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
695 let ctx = ConsumerContext::new(
696 tx,
697 tokio_util::sync::CancellationToken::new(),
698 "timer-test-route".to_string(),
699 );
700
701 tokio::spawn(async move {
702 consumer.start(ctx).await.unwrap();
703 });
704
705 let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
706 .await
707 .expect("should receive exchange")
708 .expect("envelope should exist");
709
710 let exchange = envelope.exchange;
711
712 let fired_time = exchange
714 .input
715 .header("CamelTimerFiredTime")
716 .expect("CamelTimerFiredTime header should be present");
717 assert!(
718 fired_time.is_string(),
719 "CamelTimerFiredTime should be a string"
720 );
721 let fired_str = fired_time.as_str().unwrap();
722 assert!(
724 chrono::DateTime::parse_from_rfc3339(fired_str).is_ok(),
725 "CamelTimerFiredTime should be valid RFC 3339: {fired_str}"
726 );
727
728 let msg_ts = exchange
730 .input
731 .header("CamelMessageTimestamp")
732 .expect("CamelMessageTimestamp header should be present");
733 assert!(
734 msg_ts.is_number(),
735 "CamelMessageTimestamp should be a number"
736 );
737 let ts_millis = msg_ts.as_i64().expect("should be i64");
738 assert!(ts_millis > 0, "timestamp should be positive");
739 }
740
741 #[test]
742 fn test_timer_fired_time_header_format() {
743 let now = chrono::Utc::now();
745 let rfc = now.to_rfc3339();
746 assert!(chrono::DateTime::parse_from_rfc3339(&rfc).is_ok());
747 let millis = now.timestamp_millis();
748 assert!(millis > 0);
749 }
750
751 #[test]
753 fn test_include_metadata_default_is_true() {
754 let config = TimerConfig::from_uri("timer:tick").unwrap();
755 assert!(
756 config.include_metadata,
757 "includeMetadata should default to true"
758 );
759 }
760
761 #[test]
762 fn test_include_metadata_false_from_uri() {
763 let config = TimerConfig::from_uri("timer:tick?includeMetadata=false").unwrap();
764 assert!(
765 !config.include_metadata,
766 "includeMetadata should be false when set in URI"
767 );
768 }
769
770 #[tokio::test]
771 async fn test_include_metadata_false_omits_headers() {
772 let component = TimerComponent::new();
773 let endpoint = component
774 .create_endpoint(
775 "timer:minimal?period=50&repeatCount=1&includeMetadata=false",
776 &NoOpComponentContext,
777 )
778 .unwrap();
779 let mut consumer = endpoint.create_consumer(rt()).unwrap();
780
781 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
782 let ctx = ConsumerContext::new(
783 tx,
784 tokio_util::sync::CancellationToken::new(),
785 "timer-test-route".to_string(),
786 );
787
788 tokio::spawn(async move {
789 consumer.start(ctx).await.unwrap();
790 });
791
792 let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
793 .await
794 .expect("should receive exchange")
795 .expect("envelope should exist");
796
797 let exchange = envelope.exchange;
798
799 assert!(
801 exchange.input.header("CamelTimerName").is_none(),
802 "CamelTimerName should not be present when includeMetadata=false"
803 );
804 assert!(
805 exchange.input.header("CamelTimerCounter").is_none(),
806 "CamelTimerCounter should not be present when includeMetadata=false"
807 );
808 assert!(
809 exchange.input.header("CamelTimerFiredTime").is_none(),
810 "CamelTimerFiredTime should not be present when includeMetadata=false"
811 );
812 assert!(
813 exchange.input.header("CamelMessageTimestamp").is_none(),
814 "CamelMessageTimestamp should not be present when includeMetadata=false"
815 );
816 }
817
818 #[tokio::test]
819 async fn test_include_metadata_true_includes_all_headers() {
820 let component = TimerComponent::new();
821 let endpoint = component
822 .create_endpoint(
823 "timer:full?period=50&repeatCount=1&includeMetadata=true",
824 &NoOpComponentContext,
825 )
826 .unwrap();
827 let mut consumer = endpoint.create_consumer(rt()).unwrap();
828
829 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
830 let ctx = ConsumerContext::new(
831 tx,
832 tokio_util::sync::CancellationToken::new(),
833 "timer-test-route".to_string(),
834 );
835
836 tokio::spawn(async move {
837 consumer.start(ctx).await.unwrap();
838 });
839
840 let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
841 .await
842 .expect("should receive exchange")
843 .expect("envelope should exist");
844
845 let exchange = envelope.exchange;
846
847 assert!(exchange.input.header("CamelTimerName").is_some());
848 assert!(exchange.input.header("CamelTimerCounter").is_some());
849 assert!(exchange.input.header("CamelTimerFiredTime").is_some());
850 assert!(exchange.input.header("CamelMessageTimestamp").is_some());
851 }
852
853 #[test]
855 fn test_timer_endpoint_is_pub() {
856 let component = TimerComponent::new();
857 let endpoint = component
858 .create_endpoint("timer:pub-test", &NoOpComponentContext)
859 .unwrap();
860 assert_eq!(endpoint.uri(), "timer:pub-test");
861 }
862
863 struct RecordingMetrics {
869 errors: Arc<Mutex<Vec<(String, String)>>>,
870 }
871
872 impl camel_api::MetricsCollector for RecordingMetrics {
873 fn record_exchange_duration(&self, _: &str, _: Duration) {}
874 fn increment_errors(&self, route_id: &str, error_type: &str) {
875 self.errors
876 .lock()
877 .unwrap()
878 .push((route_id.to_string(), error_type.to_string()));
879 }
880 fn increment_exchanges(&self, _: &str) {}
881 fn set_queue_depth(&self, _: &str, _: usize) {}
882 fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
883 }
884
885 struct RecordingRuntime {
886 metrics_collector: Arc<RecordingMetrics>,
887 }
888
889 impl RecordingRuntime {
890 fn new(errors: Arc<Mutex<Vec<(String, String)>>>) -> Self {
891 Self {
892 metrics_collector: Arc::new(RecordingMetrics { errors }),
893 }
894 }
895 }
896
897 impl camel_component_api::RuntimeObservability for RecordingRuntime {
898 fn metrics(&self) -> Arc<dyn camel_api::MetricsCollector> {
899 Arc::clone(&self.metrics_collector) as Arc<dyn camel_api::MetricsCollector>
900 }
901 fn health(&self) -> Arc<dyn camel_component_api::HealthCheckRegistry> {
902 panic!("RecordingRuntime::health not used in this test")
903 }
904 }
905
906 #[tokio::test]
907 async fn timer_fire_send_failure_counts_b_prime() {
908 let errors = Arc::new(Mutex::new(Vec::new()));
909 let component = TimerComponent::new();
910 let endpoint = component
911 .create_endpoint("timer:fire-send?period=20", &NoOpComponentContext)
912 .unwrap();
913
914 let mut consumer = endpoint
917 .create_consumer(Arc::new(RecordingRuntime::new(Arc::clone(&errors))))
918 .unwrap();
919 let (tx, _) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
921 let ctx = ConsumerContext::new(
922 tx,
923 tokio_util::sync::CancellationToken::new(),
924 "timer-test-route".to_string(),
925 );
926
927 consumer.start(ctx).await.unwrap();
929
930 let recorded = errors.lock().unwrap().clone();
931 assert_eq!(
932 recorded,
933 vec![(
934 "timer-test-route".to_string(),
935 "b-prime:timer:fire-send".to_string()
936 )]
937 );
938
939 consumer.stop().await.unwrap();
940 }
941}