Skip to main content

camel_core/lifecycle/application/
runtime_bus.rs

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    /// Thread a metrics handle so infrastructure commands (ReloadTlsCerts,
88    /// ReloadTemplates) can record counters (rc-d3pj). When None (default),
89    /// no counters are recorded.
90    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    /// H8 boot reconciliation: fail any route still in a transient state
100    /// (`Starting` / `Stopping`) from a previous run. Called from
101    /// `CamelContext::start()` before `auto_startup_route_ids()`.
102    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        // ── TLS cert reload intercept ──────────────────────────────────────
181        // Infrastructure command — bypasses journal recovery + dedup.
182        // Reloads are idempotent and NOT journaled.
183        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                    // rc-d3pj: record reload counter once per successful reload.
192                    if let Some(metrics) = &self.metrics {
193                        metrics.record_counter(
194                            "tls_reloads_total",
195                            1.0,
196                            &[("scheme", scheme), ("host", host)],
197                        );
198                    }
199                    return Ok(RuntimeCommandResult::TlsCertsReloaded {
200                        scheme: scheme.clone(),
201                        host: host.clone(),
202                        port: *port,
203                    });
204                }
205                None => {
206                    return Err(CamelError::Config(format!(
207                        "no TLS server found for {scheme}://{host}:{port}"
208                    )));
209                }
210            }
211        }
212        // ── End TLS reload intercept ────────────────────────────────────────
213
214        // ── Template reload intercept ──────────────────────────────────────
215        // Infrastructure command — bypasses journal recovery + dedup (mirrors
216        // ReloadTlsCerts above). Reloads are idempotent and NOT journaled;
217        // RouteStatus is not mutated and ADR-0018 is not invoked. Dispatches to
218        // the registry in camel-component-api (NOT camel-template — that would
219        // invert the dependency).
220        if let RuntimeCommand::ReloadTemplates { route_id, .. } = &cmd {
221            let route_id = route_id.clone();
222            camel_component_api::template_reload::TemplateReloadRegistry::global()
223                .reload_route(&route_id)
224                .await?;
225            // rc-d3pj: record reload counter once per successful reload.
226            if let Some(metrics) = &self.metrics {
227                metrics.record_counter(
228                    "template_reloads_total",
229                    1.0,
230                    &[("route_id", route_id.as_str())],
231                );
232            }
233            return Ok(RuntimeCommandResult::TemplatesReloaded { route_id });
234        }
235        // ── End template reload intercept ──────────────────────────────────
236
237        self.ensure_journal_recovered().await?;
238        let command_id = cmd.command_id().to_string();
239        if !self.dedup.first_seen(&command_id).await? {
240            return Ok(RuntimeCommandResult::Duplicate { command_id });
241        }
242        let deps = self.deps();
243        match execute_command(&deps, cmd).await {
244            Ok(result) => Ok(result),
245            Err(err) => {
246                let _ = self.dedup.forget_seen(&command_id).await;
247                Err(err)
248            }
249        }
250    }
251}
252
253#[async_trait]
254impl RuntimeQueryBus for RuntimeBus {
255    async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
256        self.ensure_journal_recovered().await?;
257
258        match query {
259            RuntimeQuery::InFlightCount { route_id } => {
260                if let Some(execution) = &self.execution {
261                    execution
262                        .in_flight_count(&route_id)
263                        .await
264                        .map(|r| r.into())
265                        .map_err(Into::into)
266                } else {
267                    Ok(RuntimeQueryResult::RouteNotFound { route_id })
268                }
269            }
270            other => {
271                let deps = self.query_deps();
272                execute_query(&deps, other).await
273            }
274        }
275    }
276}
277
278#[async_trait]
279impl RouteRegistrationPort for RuntimeBus {
280    async fn register_route(&self, def: RouteDefinition) -> Result<(), DomainError> {
281        self.ensure_journal_recovered()
282            .await
283            .map_err(|e| DomainError::InvalidState(e.to_string()))?;
284        let deps = self.deps();
285        handle_register_internal(&deps, def)
286            .await
287            .map(|_| ())
288            .map_err(|e| match e {
289                CamelError::RouteError(msg) => DomainError::InvalidState(msg),
290                other => DomainError::InvalidState(other.to_string()),
291            })
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use crate::lifecycle::domain::DomainError;
298
299    use super::*;
300    use std::collections::{HashMap, HashSet};
301    use std::sync::Mutex;
302
303    use crate::lifecycle::application::ports::RouteRegistrationPort as InternalRuntimeCommandBus;
304    use crate::lifecycle::application::ports::RouteStatusProjection;
305    use crate::lifecycle::application::route_definition::RouteDefinition;
306    use crate::lifecycle::domain::{RouteRuntimeAggregate, RuntimeEvent};
307
308    #[derive(Clone, Default)]
309    struct InMemoryTestRepo {
310        routes: Arc<Mutex<HashMap<String, RouteRuntimeAggregate>>>,
311    }
312
313    #[async_trait]
314    impl RouteRepositoryPort for InMemoryTestRepo {
315        async fn load(&self, route_id: &str) -> Result<Option<RouteRuntimeAggregate>, DomainError> {
316            Ok(self
317                .routes
318                .lock()
319                .expect("lock test routes")
320                .get(route_id)
321                .cloned())
322        }
323
324        async fn save(&self, aggregate: RouteRuntimeAggregate) -> Result<(), DomainError> {
325            self.routes
326                .lock()
327                .expect("lock test routes")
328                .insert(aggregate.route_id().to_string(), aggregate);
329            Ok(())
330        }
331
332        async fn save_if_version(
333            &self,
334            aggregate: RouteRuntimeAggregate,
335            expected_version: u64,
336        ) -> Result<(), DomainError> {
337            let route_id = aggregate.route_id().to_string();
338            let mut routes = self.routes.lock().expect("lock test routes");
339            let current = routes.get(&route_id).ok_or_else(|| {
340                DomainError::InvalidState(format!(
341                    "optimistic lock conflict for route '{route_id}': route not found"
342                ))
343            })?;
344
345            if current.version() != expected_version {
346                return Err(DomainError::InvalidState(format!(
347                    "optimistic lock conflict for route '{route_id}': expected version {expected_version}, actual {}",
348                    current.version()
349                )));
350            }
351
352            routes.insert(route_id, aggregate);
353            Ok(())
354        }
355
356        async fn delete(&self, route_id: &str) -> Result<(), DomainError> {
357            self.routes
358                .lock()
359                .expect("lock test routes")
360                .remove(route_id);
361            Ok(())
362        }
363    }
364
365    #[derive(Clone, Default)]
366    struct InMemoryTestProjectionStore {
367        statuses: Arc<Mutex<HashMap<String, RouteStatusProjection>>>,
368    }
369
370    #[async_trait]
371    impl ProjectionStorePort for InMemoryTestProjectionStore {
372        async fn upsert_status(&self, status: RouteStatusProjection) -> Result<(), DomainError> {
373            self.statuses
374                .lock()
375                .expect("lock test statuses")
376                .insert(status.route_id.clone(), status);
377            Ok(())
378        }
379
380        async fn get_status(
381            &self,
382            route_id: &str,
383        ) -> Result<Option<RouteStatusProjection>, DomainError> {
384            Ok(self
385                .statuses
386                .lock()
387                .expect("lock test statuses")
388                .get(route_id)
389                .cloned())
390        }
391
392        async fn list_statuses(&self) -> Result<Vec<RouteStatusProjection>, DomainError> {
393            Ok(self
394                .statuses
395                .lock()
396                .expect("lock test statuses")
397                .values()
398                .cloned()
399                .collect())
400        }
401
402        async fn remove_status(&self, route_id: &str) -> Result<(), DomainError> {
403            self.statuses
404                .lock()
405                .expect("lock test statuses")
406                .remove(route_id);
407            Ok(())
408        }
409    }
410
411    #[derive(Clone, Default)]
412    struct InMemoryTestEventPublisher;
413
414    #[async_trait]
415    impl EventPublisherPort for InMemoryTestEventPublisher {
416        async fn publish(&self, _events: &[RuntimeEvent]) -> Result<(), DomainError> {
417            Ok(())
418        }
419    }
420
421    #[derive(Clone, Default)]
422    struct InMemoryTestDedup {
423        seen: Arc<Mutex<HashSet<String>>>,
424    }
425
426    #[derive(Clone, Default)]
427    struct InspectableDedup {
428        seen: Arc<Mutex<HashSet<String>>>,
429        forget_calls: Arc<Mutex<u32>>,
430    }
431
432    #[async_trait]
433    impl CommandDedupPort for InMemoryTestDedup {
434        async fn first_seen(&self, command_id: &str) -> Result<bool, DomainError> {
435            let mut seen = self.seen.lock().expect("lock dedup set");
436            Ok(seen.insert(command_id.to_string()))
437        }
438
439        async fn forget_seen(&self, command_id: &str) -> Result<(), DomainError> {
440            self.seen.lock().expect("lock dedup set").remove(command_id);
441            Ok(())
442        }
443    }
444
445    #[async_trait]
446    impl CommandDedupPort for InspectableDedup {
447        async fn first_seen(&self, command_id: &str) -> Result<bool, DomainError> {
448            let mut seen = self.seen.lock().expect("lock dedup set");
449            Ok(seen.insert(command_id.to_string()))
450        }
451
452        async fn forget_seen(&self, command_id: &str) -> Result<(), DomainError> {
453            self.seen.lock().expect("lock dedup set").remove(command_id);
454            let mut calls = self.forget_calls.lock().expect("forget calls");
455            *calls += 1;
456            Ok(())
457        }
458    }
459
460    fn build_test_runtime_bus() -> RuntimeBus {
461        let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
462        let projections: Arc<dyn ProjectionStorePort> =
463            Arc::new(InMemoryTestProjectionStore::default());
464        let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
465        let dedup: Arc<dyn CommandDedupPort> = Arc::new(InMemoryTestDedup::default());
466        RuntimeBus::new(repo, projections, events, dedup)
467    }
468
469    #[derive(Default)]
470    struct CountingUow {
471        recover_calls: Arc<Mutex<u32>>,
472    }
473
474    #[derive(Default)]
475    struct FailingRecoverUow;
476
477    #[async_trait]
478    impl RuntimeUnitOfWorkPort for CountingUow {
479        async fn persist_upsert(
480            &self,
481            _aggregate: RouteRuntimeAggregate,
482            _expected_version: Option<u64>,
483            _projection: RouteStatusProjection,
484            _events: &[RuntimeEvent],
485        ) -> Result<(), DomainError> {
486            Ok(())
487        }
488
489        async fn persist_delete(
490            &self,
491            _route_id: &str,
492            _events: &[RuntimeEvent],
493        ) -> Result<(), DomainError> {
494            Ok(())
495        }
496
497        async fn recover_from_journal(&self) -> Result<(), DomainError> {
498            let mut calls = self.recover_calls.lock().expect("recover_calls");
499            *calls += 1;
500            Ok(())
501        }
502    }
503
504    #[async_trait]
505    impl RuntimeUnitOfWorkPort for FailingRecoverUow {
506        async fn persist_upsert(
507            &self,
508            _aggregate: RouteRuntimeAggregate,
509            _expected_version: Option<u64>,
510            _projection: RouteStatusProjection,
511            _events: &[RuntimeEvent],
512        ) -> Result<(), DomainError> {
513            Ok(())
514        }
515
516        async fn persist_delete(
517            &self,
518            _route_id: &str,
519            _events: &[RuntimeEvent],
520        ) -> Result<(), DomainError> {
521            Ok(())
522        }
523
524        async fn recover_from_journal(&self) -> Result<(), DomainError> {
525            Err(DomainError::InvalidState("recover failed".into()))
526        }
527    }
528
529    #[derive(Default)]
530    struct InFlightExecutionPort;
531
532    #[async_trait]
533    impl RuntimeExecutionPort for InFlightExecutionPort {
534        async fn register_route(&self, _definition: RouteDefinition) -> Result<(), DomainError> {
535            Ok(())
536        }
537        async fn start_route(&self, _route_id: &str) -> Result<(), DomainError> {
538            Ok(())
539        }
540        async fn stop_route(&self, _route_id: &str) -> Result<(), DomainError> {
541            Ok(())
542        }
543        async fn suspend_route(&self, _route_id: &str) -> Result<(), DomainError> {
544            Ok(())
545        }
546        async fn resume_route(&self, _route_id: &str) -> Result<(), DomainError> {
547            Ok(())
548        }
549        async fn reload_route(&self, _route_id: &str) -> Result<(), DomainError> {
550            Ok(())
551        }
552        async fn remove_route(&self, _route_id: &str) -> Result<(), DomainError> {
553            Ok(())
554        }
555        async fn in_flight_count(
556            &self,
557            route_id: &str,
558        ) -> Result<InFlightCountResult, DomainError> {
559            if route_id == "known" {
560                Ok(InFlightCountResult::InFlightCount {
561                    route_id: route_id.to_string(),
562                    count: 3,
563                })
564            } else {
565                Ok(InFlightCountResult::RouteNotFound {
566                    route_id: route_id.to_string(),
567                })
568            }
569        }
570    }
571
572    #[tokio::test]
573    async fn runtime_bus_implements_internal_command_bus() {
574        let bus = build_test_runtime_bus();
575        let def = RouteDefinition::new("timer:test", vec![]).with_route_id("internal-route");
576        let result = InternalRuntimeCommandBus::register_route(&bus, def).await;
577        assert!(
578            result.is_ok(),
579            "internal bus registration failed: {:?}",
580            result
581        );
582
583        let status = bus
584            .ask(RuntimeQuery::GetRouteStatus {
585                route_id: "internal-route".to_string(),
586            })
587            .await
588            .unwrap();
589        match status {
590            RuntimeQueryResult::RouteStatus { status, .. } => {
591                assert_eq!(status, "Registered");
592            }
593            _ => panic!("unexpected query result"),
594        }
595    }
596
597    #[tokio::test]
598    async fn execute_returns_duplicate_for_replayed_command_id() {
599        use camel_api::runtime::{CanonicalRouteSpec, CanonicalStepSpec, RuntimeCommand};
600
601        let bus = build_test_runtime_bus();
602
603        let mut spec = CanonicalRouteSpec::new("dup-route", "timer:tick");
604        spec.steps = vec![CanonicalStepSpec::Stop];
605
606        let cmd = RuntimeCommand::RegisterRoute {
607            spec: spec.clone(),
608            command_id: "dup-cmd".into(),
609            causation_id: None,
610        };
611        let first = bus.execute(cmd).await.unwrap();
612        assert!(matches!(
613            first,
614            RuntimeCommandResult::RouteRegistered { route_id } if route_id == "dup-route"
615        ));
616
617        let second = bus
618            .execute(RuntimeCommand::RegisterRoute {
619                spec,
620                command_id: "dup-cmd".into(),
621                causation_id: None,
622            })
623            .await
624            .unwrap();
625        assert!(matches!(
626            second,
627            RuntimeCommandResult::Duplicate { command_id } if command_id == "dup-cmd"
628        ));
629    }
630
631    #[tokio::test]
632    async fn ask_in_flight_count_without_execution_returns_route_not_found() {
633        let bus = build_test_runtime_bus();
634        let res = bus
635            .ask(RuntimeQuery::InFlightCount {
636                route_id: "missing".into(),
637            })
638            .await
639            .unwrap();
640        assert!(matches!(
641            res,
642            RuntimeQueryResult::RouteNotFound { route_id } if route_id == "missing"
643        ));
644    }
645
646    #[tokio::test]
647    async fn ask_in_flight_count_with_execution_delegates_to_adapter() {
648        let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
649        let projections: Arc<dyn ProjectionStorePort> =
650            Arc::new(InMemoryTestProjectionStore::default());
651        let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
652        let dedup: Arc<dyn CommandDedupPort> = Arc::new(InMemoryTestDedup::default());
653        let execution: Arc<dyn RuntimeExecutionPort> = Arc::new(InFlightExecutionPort);
654        let bus = RuntimeBus::new(repo, projections, events, dedup).with_execution(execution);
655
656        let known = bus
657            .ask(RuntimeQuery::InFlightCount {
658                route_id: "known".into(),
659            })
660            .await
661            .unwrap();
662        assert!(matches!(
663            known,
664            RuntimeQueryResult::InFlightCount { route_id, count }
665            if route_id == "known" && count == 3
666        ));
667    }
668
669    #[tokio::test]
670    async fn journal_recovery_runs_once_even_with_multiple_commands() {
671        use camel_api::runtime::{CanonicalRouteSpec, CanonicalStepSpec, RuntimeCommand};
672
673        let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
674        let projections: Arc<dyn ProjectionStorePort> =
675            Arc::new(InMemoryTestProjectionStore::default());
676        let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
677        let dedup: Arc<dyn CommandDedupPort> = Arc::new(InMemoryTestDedup::default());
678        let uow = Arc::new(CountingUow::default());
679        let bus = RuntimeBus::new(repo, projections, events, dedup).with_uow(uow.clone());
680
681        let mut spec_a = CanonicalRouteSpec::new("a", "timer:a");
682        spec_a.steps = vec![CanonicalStepSpec::Stop];
683        let mut spec_b = CanonicalRouteSpec::new("b", "timer:b");
684        spec_b.steps = vec![CanonicalStepSpec::Stop];
685
686        bus.execute(RuntimeCommand::RegisterRoute {
687            spec: spec_a,
688            command_id: "c-a".into(),
689            causation_id: None,
690        })
691        .await
692        .unwrap();
693
694        bus.execute(RuntimeCommand::RegisterRoute {
695            spec: spec_b,
696            command_id: "c-b".into(),
697            causation_id: None,
698        })
699        .await
700        .unwrap();
701
702        let calls = *uow.recover_calls.lock().expect("recover calls");
703        assert_eq!(calls, 1, "journal recovery should run once");
704    }
705
706    #[tokio::test]
707    async fn execute_on_command_error_forgets_dedup_marker() {
708        use camel_api::runtime::{CanonicalRouteSpec, RuntimeCommand};
709
710        let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
711        let projections: Arc<dyn ProjectionStorePort> =
712            Arc::new(InMemoryTestProjectionStore::default());
713        let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
714        let dedup = Arc::new(InspectableDedup::default());
715        let dedup_port: Arc<dyn CommandDedupPort> = dedup.clone();
716
717        let bus = RuntimeBus::new(repo, projections, events, dedup_port);
718
719        // Invalid canonical contract: empty route_id -> execute_command should fail.
720        let cmd = RuntimeCommand::RegisterRoute {
721            spec: CanonicalRouteSpec::new("", "timer:tick"),
722            command_id: "err-cmd".into(),
723            causation_id: None,
724        };
725
726        let err = bus.execute(cmd).await.expect_err("must fail");
727        assert!(err.to_string().contains("route_id cannot be empty"));
728
729        assert_eq!(*dedup.forget_calls.lock().expect("forget calls"), 1);
730        assert!(!dedup.seen.lock().expect("seen").contains("err-cmd"));
731    }
732
733    #[tokio::test]
734    async fn execute_propagates_recover_error_from_uow() {
735        use camel_api::runtime::{CanonicalRouteSpec, CanonicalStepSpec, RuntimeCommand};
736
737        let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
738        let projections: Arc<dyn ProjectionStorePort> =
739            Arc::new(InMemoryTestProjectionStore::default());
740        let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
741        let dedup: Arc<dyn CommandDedupPort> = Arc::new(InMemoryTestDedup::default());
742        let uow: Arc<dyn RuntimeUnitOfWorkPort> = Arc::new(FailingRecoverUow);
743
744        let bus = RuntimeBus::new(repo, projections, events, dedup).with_uow(uow);
745
746        let mut spec = CanonicalRouteSpec::new("x", "timer:x");
747        spec.steps = vec![CanonicalStepSpec::Stop];
748        let err = bus
749            .execute(RuntimeCommand::RegisterRoute {
750                spec,
751                command_id: "recover-err".into(),
752                causation_id: None,
753            })
754            .await
755            .expect_err("recover should fail");
756
757        assert!(err.to_string().contains("recover failed"));
758    }
759
760    #[tokio::test]
761    async fn ask_in_flight_count_with_execution_handles_unknown_route() {
762        let repo: Arc<dyn RouteRepositoryPort> = Arc::new(InMemoryTestRepo::default());
763        let projections: Arc<dyn ProjectionStorePort> =
764            Arc::new(InMemoryTestProjectionStore::default());
765        let events: Arc<dyn EventPublisherPort> = Arc::new(InMemoryTestEventPublisher);
766        let dedup: Arc<dyn CommandDedupPort> = Arc::new(InMemoryTestDedup::default());
767        let execution: Arc<dyn RuntimeExecutionPort> = Arc::new(InFlightExecutionPort);
768        let bus = RuntimeBus::new(repo, projections, events, dedup).with_execution(execution);
769
770        let unknown = bus
771            .ask(RuntimeQuery::InFlightCount {
772                route_id: "unknown".into(),
773            })
774            .await
775            .unwrap();
776        assert!(matches!(
777            unknown,
778            RuntimeQueryResult::RouteNotFound { route_id } if route_id == "unknown"
779        ));
780    }
781}