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