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                        // allow-open-label rc-ycts (scheme/host: TLS listener config values)
194                        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        // ── End TLS reload intercept ────────────────────────────────────────
214
215        // ── Template reload intercept ──────────────────────────────────────
216        // Infrastructure command — bypasses journal recovery + dedup (mirrors
217        // ReloadTlsCerts above). Reloads are idempotent and NOT journaled;
218        // RouteStatus is not mutated and ADR-0018 is not invoked. Dispatches to
219        // the registry in camel-component-api (NOT camel-template — that would
220        // invert the dependency).
221        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            // rc-d3pj: record reload counter once per successful reload.
227            if let Some(metrics) = &self.metrics {
228                // allow-open-label rc-xl5k (route label: user-defined route id)
229                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        // ── End template reload intercept ──────────────────────────────────
238
239        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        // Invalid canonical contract: empty route_id -> execute_command should fail.
722        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        // rc-slvd: bus-level dedup pin — the failure watcher retries with
787        // the SAME command_id; the second execute must return the dedup
788        // outcome and cause exactly ONE lifecycle transition to Failed.
789        // REAL bus (a fake would make dedup vacuously green).
790        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        // Exactly ONE transition: aggregate Failed at version 1 (0 was the
824        // register), and the status query agrees.
825        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}