1use std::sync::Arc;
2
3use async_trait::async_trait;
4use tokio::sync::OnceCell;
5
6use camel_api::{
7 CamelError, MetricsCollector, RuntimeCommand, RuntimeCommandBus, RuntimeCommandResult,
8 RuntimeQuery, RuntimeQueryBus, RuntimeQueryResult,
9};
10
11use crate::lifecycle::application::commands::{
12 CommandDeps, execute_command, handle_register_internal,
13};
14use crate::lifecycle::application::ports::RouteRegistrationPort;
15use crate::lifecycle::application::ports::{
16 CommandDedupPort, EventPublisherPort, InFlightCountResult, ProjectionStorePort,
17 RouteRepositoryPort, RuntimeExecutionPort, RuntimeUnitOfWorkPort,
18};
19use crate::lifecycle::application::queries::{QueryDeps, execute_query};
20use crate::lifecycle::application::route_definition::RouteDefinition;
21use crate::lifecycle::domain::DomainError;
22use camel_component_api::HealthCheckRegistry as HealthCheckRegistryTrait;
23
24impl From<InFlightCountResult> for RuntimeQueryResult {
25 fn from(r: InFlightCountResult) -> Self {
26 match r {
27 InFlightCountResult::InFlightCount { route_id, count } => {
28 RuntimeQueryResult::InFlightCount { route_id, count }
29 }
30 InFlightCountResult::RouteNotFound { route_id } => {
31 RuntimeQueryResult::RouteNotFound { route_id }
32 }
33 }
34 }
35}
36
37pub struct RuntimeBus {
38 repo: Arc<dyn RouteRepositoryPort>,
39 projections: Arc<dyn ProjectionStorePort>,
40 events: Arc<dyn EventPublisherPort>,
41 dedup: Arc<dyn CommandDedupPort>,
42 uow: Option<Arc<dyn RuntimeUnitOfWorkPort>>,
43 execution: Option<Arc<dyn RuntimeExecutionPort>>,
44 health_registry: Option<Arc<dyn HealthCheckRegistryTrait>>,
45 metrics: Option<Arc<dyn MetricsCollector>>,
46 journal_recovered_once: OnceCell<()>,
47}
48
49impl RuntimeBus {
50 pub fn new(
51 repo: Arc<dyn RouteRepositoryPort>,
52 projections: Arc<dyn ProjectionStorePort>,
53 events: Arc<dyn EventPublisherPort>,
54 dedup: Arc<dyn CommandDedupPort>,
55 ) -> Self {
56 Self {
57 repo,
58 projections,
59 events,
60 dedup,
61 uow: None,
62 execution: None,
63 health_registry: None,
64 metrics: None,
65 journal_recovered_once: OnceCell::new(),
66 }
67 }
68
69 pub fn with_uow(mut self, uow: Arc<dyn RuntimeUnitOfWorkPort>) -> Self {
70 self.uow = Some(uow);
71 self
72 }
73
74 pub fn with_execution(mut self, execution: Arc<dyn RuntimeExecutionPort>) -> Self {
75 self.execution = Some(execution);
76 self
77 }
78
79 pub fn with_health_registry(
80 mut self,
81 health_registry: Arc<crate::health_registry::HealthCheckRegistry>,
82 ) -> Self {
83 self.health_registry = Some(health_registry);
84 self
85 }
86
87 pub fn with_metrics(mut self, metrics: Arc<dyn MetricsCollector>) -> Self {
91 self.metrics = Some(metrics);
92 self
93 }
94
95 pub fn repo(&self) -> &Arc<dyn RouteRepositoryPort> {
96 &self.repo
97 }
98
99 pub async fn reconcile_transient_states(&self) -> Result<(), CamelError> {
103 self.ensure_journal_recovered().await?;
104 let deps = self.deps();
105 crate::lifecycle::application::commands::reconcile_transient_states(&deps).await
106 }
107
108 pub(crate) async fn register_aggregate_only(&self, route_id: String) -> Result<(), CamelError> {
109 self.ensure_journal_recovered().await?;
110 let deps = self.deps();
111 if deps.repo.load(&route_id).await?.is_some() {
112 return Err(CamelError::RouteError(format!(
113 "route '{route_id}' already registered"
114 )));
115 }
116 let (aggregate, events) =
117 crate::lifecycle::domain::RouteRuntimeAggregate::register(route_id.clone());
118 if let Some(uow) = &deps.uow {
119 uow.persist_upsert(
120 aggregate.clone(),
121 None,
122 crate::lifecycle::application::commands::project_from_aggregate(&aggregate),
123 &events,
124 )
125 .await?;
126 } else {
127 deps.repo.save(aggregate.clone()).await?;
128 if let Some(primary_error) =
129 crate::lifecycle::application::commands::upsert_projection_with_reconciliation(
130 &*deps.projections,
131 crate::lifecycle::application::commands::project_from_aggregate(&aggregate),
132 )
133 .await?
134 {
135 deps.events.publish(&events).await?;
136 return Err(CamelError::RouteError(format!(
137 "post-effect reconciliation recovered after runtime persistence error: {primary_error}"
138 )));
139 }
140 deps.events.publish(&events).await?;
141 }
142 Ok(())
143 }
144
145 fn deps(&self) -> CommandDeps {
146 CommandDeps {
147 repo: Arc::clone(&self.repo),
148 projections: Arc::clone(&self.projections),
149 events: Arc::clone(&self.events),
150 uow: self.uow.clone(),
151 execution: self.execution.clone(),
152 health_registry: self.health_registry.clone(),
153 }
154 }
155
156 fn query_deps(&self) -> QueryDeps {
157 QueryDeps {
158 projections: Arc::clone(&self.projections),
159 }
160 }
161
162 async fn ensure_journal_recovered(&self) -> Result<(), CamelError> {
163 let Some(uow) = &self.uow else {
164 return Ok(());
165 };
166
167 self.journal_recovered_once
168 .get_or_try_init(|| async {
169 uow.recover_from_journal().await?;
170 Ok::<(), CamelError>(())
171 })
172 .await?;
173 Ok(())
174 }
175}
176
177#[async_trait]
178impl RuntimeCommandBus for RuntimeBus {
179 async fn execute(&self, cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError> {
180 if let RuntimeCommand::ReloadTlsCerts {
184 scheme, host, port, ..
185 } = &cmd
186 {
187 let registry = camel_component_api::tls_source::TlsReloadRegistry::global();
188 match registry.find(scheme, host, *port) {
189 Some(handler) => {
190 handler.reload().await?;
191 if let Some(metrics) = &self.metrics {
193 metrics.record_counter(
195 "tls_reloads_total",
196 1.0,
197 &[("scheme", scheme), ("host", host)],
198 );
199 }
200 return Ok(RuntimeCommandResult::TlsCertsReloaded {
201 scheme: scheme.clone(),
202 host: host.clone(),
203 port: *port,
204 });
205 }
206 None => {
207 return Err(CamelError::Config(format!(
208 "no TLS server found for {scheme}://{host}:{port}"
209 )));
210 }
211 }
212 }
213 if let RuntimeCommand::ReloadTemplates { route_id, .. } = &cmd {
222 let route_id = route_id.clone();
223 camel_component_api::template_reload::TemplateReloadRegistry::global()
224 .reload_route(&route_id)
225 .await?;
226 if let Some(metrics) = &self.metrics {
228 metrics.record_counter(
230 "template_reloads_total",
231 1.0,
232 &[("route_id", route_id.as_str())],
233 );
234 }
235 return Ok(RuntimeCommandResult::TemplatesReloaded { route_id });
236 }
237 self.ensure_journal_recovered().await?;
240 let command_id = cmd.command_id().to_string();
241 if !self.dedup.first_seen(&command_id).await? {
242 return Ok(RuntimeCommandResult::Duplicate { command_id });
243 }
244 let deps = self.deps();
245 match execute_command(&deps, cmd).await {
246 Ok(result) => Ok(result),
247 Err(err) => {
248 let _ = self.dedup.forget_seen(&command_id).await;
249 Err(err)
250 }
251 }
252 }
253}
254
255#[async_trait]
256impl RuntimeQueryBus for RuntimeBus {
257 async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
258 self.ensure_journal_recovered().await?;
259
260 match query {
261 RuntimeQuery::InFlightCount { route_id } => {
262 if let Some(execution) = &self.execution {
263 execution
264 .in_flight_count(&route_id)
265 .await
266 .map(|r| r.into())
267 .map_err(Into::into)
268 } else {
269 Ok(RuntimeQueryResult::RouteNotFound { route_id })
270 }
271 }
272 other => {
273 let deps = self.query_deps();
274 execute_query(&deps, other).await
275 }
276 }
277 }
278}
279
280#[async_trait]
281impl RouteRegistrationPort for RuntimeBus {
282 async fn register_route(&self, def: RouteDefinition) -> Result<(), DomainError> {
283 self.ensure_journal_recovered()
284 .await
285 .map_err(|e| DomainError::InvalidState(e.to_string()))?;
286 let deps = self.deps();
287 handle_register_internal(&deps, def)
288 .await
289 .map(|_| ())
290 .map_err(|e| match e {
291 CamelError::RouteError(msg) => DomainError::InvalidState(msg),
292 other => DomainError::InvalidState(other.to_string()),
293 })
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use crate::lifecycle::domain::DomainError;
300
301 use super::*;
302 use std::collections::{HashMap, HashSet};
303 use std::sync::Mutex;
304
305 use crate::lifecycle::application::ports::RouteRegistrationPort as InternalRuntimeCommandBus;
306 use crate::lifecycle::application::ports::RouteStatusProjection;
307 use crate::lifecycle::application::route_definition::RouteDefinition;
308 use crate::lifecycle::domain::{RouteRuntimeAggregate, RuntimeEvent};
309
310 #[derive(Clone, Default)]
311 struct InMemoryTestRepo {
312 routes: Arc<Mutex<HashMap<String, RouteRuntimeAggregate>>>,
313 }
314
315 #[async_trait]
316 impl RouteRepositoryPort for InMemoryTestRepo {
317 async fn load(&self, route_id: &str) -> Result<Option<RouteRuntimeAggregate>, DomainError> {
318 Ok(self
319 .routes
320 .lock()
321 .expect("lock test routes")
322 .get(route_id)
323 .cloned())
324 }
325
326 async fn save(&self, aggregate: RouteRuntimeAggregate) -> Result<(), DomainError> {
327 self.routes
328 .lock()
329 .expect("lock test routes")
330 .insert(aggregate.route_id().to_string(), aggregate);
331 Ok(())
332 }
333
334 async fn save_if_version(
335 &self,
336 aggregate: RouteRuntimeAggregate,
337 expected_version: u64,
338 ) -> Result<(), DomainError> {
339 let route_id = aggregate.route_id().to_string();
340 let mut routes = self.routes.lock().expect("lock test routes");
341 let current = routes.get(&route_id).ok_or_else(|| {
342 DomainError::InvalidState(format!(
343 "optimistic lock conflict for route '{route_id}': route not found"
344 ))
345 })?;
346
347 if current.version() != expected_version {
348 return Err(DomainError::InvalidState(format!(
349 "optimistic lock conflict for route '{route_id}': expected version {expected_version}, actual {}",
350 current.version()
351 )));
352 }
353
354 routes.insert(route_id, aggregate);
355 Ok(())
356 }
357
358 async fn delete(&self, route_id: &str) -> Result<(), DomainError> {
359 self.routes
360 .lock()
361 .expect("lock test routes")
362 .remove(route_id);
363 Ok(())
364 }
365 }
366
367 #[derive(Clone, Default)]
368 struct InMemoryTestProjectionStore {
369 statuses: Arc<Mutex<HashMap<String, RouteStatusProjection>>>,
370 }
371
372 #[async_trait]
373 impl ProjectionStorePort for InMemoryTestProjectionStore {
374 async fn upsert_status(&self, status: RouteStatusProjection) -> Result<(), DomainError> {
375 self.statuses
376 .lock()
377 .expect("lock test statuses")
378 .insert(status.route_id.clone(), status);
379 Ok(())
380 }
381
382 async fn get_status(
383 &self,
384 route_id: &str,
385 ) -> Result<Option<RouteStatusProjection>, DomainError> {
386 Ok(self
387 .statuses
388 .lock()
389 .expect("lock test statuses")
390 .get(route_id)
391 .cloned())
392 }
393
394 async fn list_statuses(&self) -> Result<Vec<RouteStatusProjection>, DomainError> {
395 Ok(self
396 .statuses
397 .lock()
398 .expect("lock test statuses")
399 .values()
400 .cloned()
401 .collect())
402 }
403
404 async fn remove_status(&self, route_id: &str) -> Result<(), DomainError> {
405 self.statuses
406 .lock()
407 .expect("lock test statuses")
408 .remove(route_id);
409 Ok(())
410 }
411 }
412
413 #[derive(Clone, Default)]
414 struct InMemoryTestEventPublisher;
415
416 #[async_trait]
417 impl EventPublisherPort for InMemoryTestEventPublisher {
418 async fn publish(&self, _events: &[RuntimeEvent]) -> Result<(), DomainError> {
419 Ok(())
420 }
421 }
422
423 #[derive(Clone, Default)]
424 struct InMemoryTestDedup {
425 seen: Arc<Mutex<HashSet<String>>>,
426 }
427
428 #[derive(Clone, Default)]
429 struct InspectableDedup {
430 seen: Arc<Mutex<HashSet<String>>>,
431 forget_calls: Arc<Mutex<u32>>,
432 }
433
434 #[async_trait]
435 impl CommandDedupPort for InMemoryTestDedup {
436 async fn first_seen(&self, command_id: &str) -> Result<bool, DomainError> {
437 let mut seen = self.seen.lock().expect("lock dedup set");
438 Ok(seen.insert(command_id.to_string()))
439 }
440
441 async fn forget_seen(&self, command_id: &str) -> Result<(), DomainError> {
442 self.seen.lock().expect("lock dedup set").remove(command_id);
443 Ok(())
444 }
445 }
446
447 #[async_trait]
448 impl CommandDedupPort for InspectableDedup {
449 async fn first_seen(&self, command_id: &str) -> Result<bool, DomainError> {
450 let mut seen = self.seen.lock().expect("lock dedup set");
451 Ok(seen.insert(command_id.to_string()))
452 }
453
454 async fn forget_seen(&self, command_id: &str) -> Result<(), DomainError> {
455 self.seen.lock().expect("lock dedup set").remove(command_id);
456 let mut calls = self.forget_calls.lock().expect("forget calls");
457 *calls += 1;
458 Ok(())
459 }
460 }
461
462 fn build_test_runtime_bus() -> RuntimeBus {
463 let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
464 let projections: Arc<dyn ProjectionStorePort> =
465 Arc::new(InMemoryTestProjectionStore::default());
466 let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
467 let dedup: Arc<dyn CommandDedupPort> = Arc::new(InMemoryTestDedup::default());
468 RuntimeBus::new(repo, projections, events, dedup)
469 }
470
471 #[derive(Default)]
472 struct CountingUow {
473 recover_calls: Arc<Mutex<u32>>,
474 }
475
476 #[derive(Default)]
477 struct FailingRecoverUow;
478
479 #[async_trait]
480 impl RuntimeUnitOfWorkPort for CountingUow {
481 async fn persist_upsert(
482 &self,
483 _aggregate: RouteRuntimeAggregate,
484 _expected_version: Option<u64>,
485 _projection: RouteStatusProjection,
486 _events: &[RuntimeEvent],
487 ) -> Result<(), DomainError> {
488 Ok(())
489 }
490
491 async fn persist_delete(
492 &self,
493 _route_id: &str,
494 _events: &[RuntimeEvent],
495 ) -> Result<(), DomainError> {
496 Ok(())
497 }
498
499 async fn recover_from_journal(&self) -> Result<(), DomainError> {
500 let mut calls = self.recover_calls.lock().expect("recover_calls");
501 *calls += 1;
502 Ok(())
503 }
504 }
505
506 #[async_trait]
507 impl RuntimeUnitOfWorkPort for FailingRecoverUow {
508 async fn persist_upsert(
509 &self,
510 _aggregate: RouteRuntimeAggregate,
511 _expected_version: Option<u64>,
512 _projection: RouteStatusProjection,
513 _events: &[RuntimeEvent],
514 ) -> Result<(), DomainError> {
515 Ok(())
516 }
517
518 async fn persist_delete(
519 &self,
520 _route_id: &str,
521 _events: &[RuntimeEvent],
522 ) -> Result<(), DomainError> {
523 Ok(())
524 }
525
526 async fn recover_from_journal(&self) -> Result<(), DomainError> {
527 Err(DomainError::InvalidState("recover failed".into()))
528 }
529 }
530
531 #[derive(Default)]
532 struct InFlightExecutionPort;
533
534 #[async_trait]
535 impl RuntimeExecutionPort for InFlightExecutionPort {
536 async fn register_route(&self, _definition: RouteDefinition) -> Result<(), DomainError> {
537 Ok(())
538 }
539 async fn start_route(&self, _route_id: &str) -> Result<(), DomainError> {
540 Ok(())
541 }
542 async fn stop_route(&self, _route_id: &str) -> Result<(), DomainError> {
543 Ok(())
544 }
545 async fn suspend_route(&self, _route_id: &str) -> Result<(), DomainError> {
546 Ok(())
547 }
548 async fn resume_route(&self, _route_id: &str) -> Result<(), DomainError> {
549 Ok(())
550 }
551 async fn reload_route(&self, _route_id: &str) -> Result<(), DomainError> {
552 Ok(())
553 }
554 async fn remove_route(&self, _route_id: &str) -> Result<(), DomainError> {
555 Ok(())
556 }
557 async fn in_flight_count(
558 &self,
559 route_id: &str,
560 ) -> Result<InFlightCountResult, DomainError> {
561 if route_id == "known" {
562 Ok(InFlightCountResult::InFlightCount {
563 route_id: route_id.to_string(),
564 count: 3,
565 })
566 } else {
567 Ok(InFlightCountResult::RouteNotFound {
568 route_id: route_id.to_string(),
569 })
570 }
571 }
572 }
573
574 #[tokio::test]
575 async fn runtime_bus_implements_internal_command_bus() {
576 let bus = build_test_runtime_bus();
577 let def = RouteDefinition::new("timer:test", vec![]).with_route_id("internal-route");
578 let result = InternalRuntimeCommandBus::register_route(&bus, def).await;
579 assert!(
580 result.is_ok(),
581 "internal bus registration failed: {:?}",
582 result
583 );
584
585 let status = bus
586 .ask(RuntimeQuery::GetRouteStatus {
587 route_id: "internal-route".to_string(),
588 })
589 .await
590 .unwrap();
591 match status {
592 RuntimeQueryResult::RouteStatus { status, .. } => {
593 assert_eq!(status, "Registered");
594 }
595 _ => panic!("unexpected query result"),
596 }
597 }
598
599 #[tokio::test]
600 async fn execute_returns_duplicate_for_replayed_command_id() {
601 use camel_api::runtime::{CanonicalRouteSpec, CanonicalStepSpec, RuntimeCommand};
602
603 let bus = build_test_runtime_bus();
604
605 let mut spec = CanonicalRouteSpec::new("dup-route", "timer:tick");
606 spec.steps = vec![CanonicalStepSpec::Stop];
607
608 let cmd = RuntimeCommand::RegisterRoute {
609 spec: spec.clone(),
610 command_id: "dup-cmd".into(),
611 causation_id: None,
612 };
613 let first = bus.execute(cmd).await.unwrap();
614 assert!(matches!(
615 first,
616 RuntimeCommandResult::RouteRegistered { route_id } if route_id == "dup-route"
617 ));
618
619 let second = bus
620 .execute(RuntimeCommand::RegisterRoute {
621 spec,
622 command_id: "dup-cmd".into(),
623 causation_id: None,
624 })
625 .await
626 .unwrap();
627 assert!(matches!(
628 second,
629 RuntimeCommandResult::Duplicate { command_id } if command_id == "dup-cmd"
630 ));
631 }
632
633 #[tokio::test]
634 async fn ask_in_flight_count_without_execution_returns_route_not_found() {
635 let bus = build_test_runtime_bus();
636 let res = bus
637 .ask(RuntimeQuery::InFlightCount {
638 route_id: "missing".into(),
639 })
640 .await
641 .unwrap();
642 assert!(matches!(
643 res,
644 RuntimeQueryResult::RouteNotFound { route_id } if route_id == "missing"
645 ));
646 }
647
648 #[tokio::test]
649 async fn ask_in_flight_count_with_execution_delegates_to_adapter() {
650 let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
651 let projections: Arc<dyn ProjectionStorePort> =
652 Arc::new(InMemoryTestProjectionStore::default());
653 let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
654 let dedup: Arc<dyn CommandDedupPort> = Arc::new(InMemoryTestDedup::default());
655 let execution: Arc<dyn RuntimeExecutionPort> = Arc::new(InFlightExecutionPort);
656 let bus = RuntimeBus::new(repo, projections, events, dedup).with_execution(execution);
657
658 let known = bus
659 .ask(RuntimeQuery::InFlightCount {
660 route_id: "known".into(),
661 })
662 .await
663 .unwrap();
664 assert!(matches!(
665 known,
666 RuntimeQueryResult::InFlightCount { route_id, count }
667 if route_id == "known" && count == 3
668 ));
669 }
670
671 #[tokio::test]
672 async fn journal_recovery_runs_once_even_with_multiple_commands() {
673 use camel_api::runtime::{CanonicalRouteSpec, CanonicalStepSpec, RuntimeCommand};
674
675 let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
676 let projections: Arc<dyn ProjectionStorePort> =
677 Arc::new(InMemoryTestProjectionStore::default());
678 let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
679 let dedup: Arc<dyn CommandDedupPort> = Arc::new(InMemoryTestDedup::default());
680 let uow = Arc::new(CountingUow::default());
681 let bus = RuntimeBus::new(repo, projections, events, dedup).with_uow(uow.clone());
682
683 let mut spec_a = CanonicalRouteSpec::new("a", "timer:a");
684 spec_a.steps = vec![CanonicalStepSpec::Stop];
685 let mut spec_b = CanonicalRouteSpec::new("b", "timer:b");
686 spec_b.steps = vec![CanonicalStepSpec::Stop];
687
688 bus.execute(RuntimeCommand::RegisterRoute {
689 spec: spec_a,
690 command_id: "c-a".into(),
691 causation_id: None,
692 })
693 .await
694 .unwrap();
695
696 bus.execute(RuntimeCommand::RegisterRoute {
697 spec: spec_b,
698 command_id: "c-b".into(),
699 causation_id: None,
700 })
701 .await
702 .unwrap();
703
704 let calls = *uow.recover_calls.lock().expect("recover calls");
705 assert_eq!(calls, 1, "journal recovery should run once");
706 }
707
708 #[tokio::test]
709 async fn execute_on_command_error_forgets_dedup_marker() {
710 use camel_api::runtime::{CanonicalRouteSpec, RuntimeCommand};
711
712 let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
713 let projections: Arc<dyn ProjectionStorePort> =
714 Arc::new(InMemoryTestProjectionStore::default());
715 let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
716 let dedup = Arc::new(InspectableDedup::default());
717 let dedup_port: Arc<dyn CommandDedupPort> = dedup.clone();
718
719 let bus = RuntimeBus::new(repo, projections, events, dedup_port);
720
721 let cmd = RuntimeCommand::RegisterRoute {
723 spec: CanonicalRouteSpec::new("", "timer:tick"),
724 command_id: "err-cmd".into(),
725 causation_id: None,
726 };
727
728 let err = bus.execute(cmd).await.expect_err("must fail");
729 assert!(err.to_string().contains("route_id cannot be empty"));
730
731 assert_eq!(*dedup.forget_calls.lock().expect("forget calls"), 1);
732 assert!(!dedup.seen.lock().expect("seen").contains("err-cmd"));
733 }
734
735 #[tokio::test]
736 async fn execute_propagates_recover_error_from_uow() {
737 use camel_api::runtime::{CanonicalRouteSpec, CanonicalStepSpec, RuntimeCommand};
738
739 let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
740 let projections: Arc<dyn ProjectionStorePort> =
741 Arc::new(InMemoryTestProjectionStore::default());
742 let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
743 let dedup: Arc<dyn CommandDedupPort> = Arc::new(InMemoryTestDedup::default());
744 let uow: Arc<dyn RuntimeUnitOfWorkPort> = Arc::new(FailingRecoverUow);
745
746 let bus = RuntimeBus::new(repo, projections, events, dedup).with_uow(uow);
747
748 let mut spec = CanonicalRouteSpec::new("x", "timer:x");
749 spec.steps = vec![CanonicalStepSpec::Stop];
750 let err = bus
751 .execute(RuntimeCommand::RegisterRoute {
752 spec,
753 command_id: "recover-err".into(),
754 causation_id: None,
755 })
756 .await
757 .expect_err("recover should fail");
758
759 assert!(err.to_string().contains("recover failed"));
760 }
761
762 #[tokio::test]
763 async fn ask_in_flight_count_with_execution_handles_unknown_route() {
764 let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
765 let projections: Arc<dyn ProjectionStorePort> =
766 Arc::new(InMemoryTestProjectionStore::default());
767 let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
768 let dedup: Arc<dyn CommandDedupPort> = Arc::new(InMemoryTestDedup::default());
769 let execution: Arc<dyn RuntimeExecutionPort> = Arc::new(InFlightExecutionPort);
770 let bus = RuntimeBus::new(repo, projections, events, dedup).with_execution(execution);
771
772 let unknown = bus
773 .ask(RuntimeQuery::InFlightCount {
774 route_id: "unknown".into(),
775 })
776 .await
777 .unwrap();
778 assert!(matches!(
779 unknown,
780 RuntimeQueryResult::RouteNotFound { route_id } if route_id == "unknown"
781 ));
782 }
783
784 #[tokio::test]
785 async fn watcher_duplicate_failroute_is_noop() {
786 use crate::lifecycle::domain::RouteRuntimeState;
791 use camel_api::RuntimeCommand;
792
793 let bus = build_test_runtime_bus();
794 let def = RouteDefinition::new("timer:test", vec![]).with_route_id("dup-fail-route");
795 InternalRuntimeCommandBus::register_route(&bus, def)
796 .await
797 .expect("register route");
798
799 let cmd = RuntimeCommand::FailRoute {
800 route_id: "dup-fail-route".into(),
801 error: "watcher".into(),
802 command_id: "fail-once".into(),
803 causation_id: None,
804 };
805 let first = bus.execute(cmd.clone()).await.unwrap();
806 assert!(
807 matches!(
808 &first,
809 RuntimeCommandResult::RouteStateChanged { status, .. } if status == "Failed"
810 ),
811 "first FailRoute must transition to Failed, got {first:?}"
812 );
813
814 let second = bus.execute(cmd).await.unwrap();
815 assert!(
816 matches!(
817 &second,
818 RuntimeCommandResult::Duplicate { command_id } if command_id == "fail-once"
819 ),
820 "duplicate command_id must return the dedup no-op, got {second:?}"
821 );
822
823 let aggregate = bus
826 .repo()
827 .load("dup-fail-route")
828 .await
829 .unwrap()
830 .expect("route exists");
831 assert!(matches!(aggregate.state(), RouteRuntimeState::Failed(_)));
832 assert_eq!(
833 aggregate.version(),
834 1,
835 "the duplicate must not apply a second transition"
836 );
837
838 let status = bus
839 .ask(RuntimeQuery::GetRouteStatus {
840 route_id: "dup-fail-route".into(),
841 })
842 .await
843 .unwrap();
844 match status {
845 RuntimeQueryResult::RouteStatus { status, .. } => assert_eq!(status, "Failed"),
846 other => panic!("unexpected query result: {other:?}"),
847 }
848 }
849}