use std::any::Any;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use camel_api::{
MetricsCollector, RuntimeCommand, RuntimeCommandBus, RuntimeCommandResult, RuntimeQuery,
RuntimeQueryBus, RuntimeQueryResult,
};
use camel_component_api::template_reload::{
TemplateReloadRegistry, TemplateReloadStaged, TemplateReloadTarget,
};
use camel_core::{
InMemoryCommandDedup, InMemoryEventPublisher, InMemoryProjectionStore, InMemoryRouteRepository,
JournalDurability, RedbJournalOptions, RedbRuntimeEventJournal, RuntimeBus,
RuntimeEventJournalPort,
};
use tempfile::tempdir;
struct FakeStaged;
impl TemplateReloadStaged for FakeStaged {
fn into_any(self: Box<Self>) -> Box<dyn Any> {
self
}
}
struct FakeTarget {
route: String,
commit_calls: Arc<AtomicUsize>,
generation: AtomicU64,
}
impl FakeTarget {
fn new(route: &str) -> Arc<Self> {
Arc::new(Self {
route: route.to_string(),
commit_calls: Arc::new(AtomicUsize::new(0)),
generation: AtomicU64::new(0),
})
}
fn as_dyn(self: &Arc<Self>) -> Arc<dyn TemplateReloadTarget> {
let concrete: Arc<Self> = Arc::clone(self);
concrete
}
}
#[async_trait]
impl TemplateReloadTarget for FakeTarget {
fn route_id(&self) -> &str {
&self.route
}
fn reload_timeout(&self) -> Duration {
Duration::from_secs(5)
}
fn current_generation(&self) -> u64 {
self.generation.load(Ordering::SeqCst)
}
async fn build(&self) -> Result<(Box<dyn TemplateReloadStaged>, u64), camel_api::CamelError> {
Ok((Box::new(FakeStaged), self.generation.load(Ordering::SeqCst)))
}
fn commit(&self, _staged: Box<dyn TemplateReloadStaged>) {
self.commit_calls.fetch_add(1, Ordering::SeqCst);
self.generation.fetch_add(1, Ordering::SeqCst);
}
}
fn build_test_bus() -> RuntimeBus {
RuntimeBus::new(
Arc::new(InMemoryRouteRepository::default()),
Arc::new(InMemoryProjectionStore::default()),
Arc::new(InMemoryEventPublisher::default()),
Arc::new(InMemoryCommandDedup::default()),
)
}
async fn new_journal(path: std::path::PathBuf) -> Arc<RedbRuntimeEventJournal> {
Arc::new(
RedbRuntimeEventJournal::new(
path,
RedbJournalOptions {
durability: JournalDurability::Eventual,
compaction_threshold_events: 10_000,
},
)
.await
.unwrap(),
)
}
#[tokio::test]
async fn reload_templates_bypasses_dedup() {
let route = "tpl-bypass-dedup";
let target = FakeTarget::new(route);
let commit_calls = Arc::clone(&target.commit_calls);
let _guard = TemplateReloadRegistry::global().register(target.as_dyn());
let bus = build_test_bus();
for i in 0..3 {
let _ = bus
.execute(RuntimeCommand::ReloadTemplates {
route_id: route.to_string(),
command_id: "same-cmd-id".into(),
causation_id: None,
})
.await
.unwrap_or_else(|e| panic!("reload #{i} should succeed: {e}"));
}
assert_eq!(
commit_calls.load(Ordering::SeqCst),
3,
"ReloadTemplates must bypass dedup and invoke reload_route every time"
);
}
#[tokio::test]
async fn reload_templates_does_not_require_journal() {
let route = "tpl-no-journal";
let target = FakeTarget::new(route);
let _guard = TemplateReloadRegistry::global().register(target.as_dyn());
let bus = build_test_bus();
let result = bus
.execute(RuntimeCommand::ReloadTemplates {
route_id: route.to_string(),
command_id: "cmd-no-uow".into(),
causation_id: None,
})
.await;
assert!(
result.is_ok(),
"intercept must bypass UoW/journal: {result:?}"
);
let result = result.unwrap();
assert!(
matches!(result, RuntimeCommandResult::TemplatesReloaded { ref route_id } if route_id == route),
"expected TemplatesReloaded, got {result:?}"
);
}
#[tokio::test]
async fn reload_templates_route_status_unchanged() {
let route = "tpl-status-unchanged";
let dir = tempdir().unwrap();
let journal = new_journal(dir.path().join("tpl-status.db")).await;
let store = camel_core::InMemoryRuntimeStore::default().with_journal(journal.clone());
let runtime = RuntimeBus::new(
Arc::new(store.clone()),
Arc::new(store.clone()),
Arc::new(store.clone()),
Arc::new(store.clone()),
)
.with_uow(Arc::new(store.clone()));
runtime
.execute(RuntimeCommand::RegisterRoute {
spec: camel_api::CanonicalRouteSpec::new(route, "timer:tick"),
command_id: "tpl-status-c1".to_string(),
causation_id: None,
})
.await
.unwrap();
runtime
.execute(RuntimeCommand::StartRoute {
route_id: route.to_string(),
command_id: "tpl-status-c2".to_string(),
causation_id: Some("tpl-status-c1".to_string()),
})
.await
.unwrap();
let events_before = journal.load_all().await.unwrap().len();
let status_before = runtime
.ask(RuntimeQuery::GetRouteStatus {
route_id: route.to_string(),
})
.await
.unwrap();
assert_eq!(
status_before,
RuntimeQueryResult::RouteStatus {
route_id: route.to_string(),
status: "Started".to_string(),
}
);
let target = FakeTarget::new(route);
let _guard = TemplateReloadRegistry::global().register(target.as_dyn());
let reload_result = runtime
.execute(RuntimeCommand::ReloadTemplates {
route_id: route.to_string(),
command_id: "tpl-status-c3".to_string(),
causation_id: None,
})
.await;
assert!(
reload_result.is_ok(),
"ReloadTemplates should succeed: {reload_result:?}"
);
let status_after = runtime
.ask(RuntimeQuery::GetRouteStatus {
route_id: route.to_string(),
})
.await
.unwrap();
assert_eq!(
status_after, status_before,
"RouteStatus must be unchanged by ReloadTemplates"
);
let events_after = journal.load_all().await.unwrap().len();
assert_eq!(
events_after, events_before,
"ReloadTemplates must not append journal events (before={events_before}, after={events_after})"
);
}
struct RecordingMetrics {
counters: std::sync::Mutex<Vec<CounterRecord>>,
}
#[derive(Clone, Debug)]
struct CounterRecord {
name: String,
value: f64,
labels: Vec<(String, String)>,
}
impl MetricsCollector for RecordingMetrics {
fn record_exchange_duration(&self, _: &str, _: Duration) {}
fn increment_errors(&self, _: &str, _: &str) {}
fn increment_exchanges(&self, _: &str) {}
fn set_queue_depth(&self, _: &str, _: usize) {}
fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
self.counters.lock().unwrap().push(CounterRecord {
name: name.to_string(),
value,
labels: labels
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
});
}
}
#[tokio::test]
async fn reload_templates_records_counter() {
let route = "tpl-metrics-counter";
let target = FakeTarget::new(route);
let _guard = TemplateReloadRegistry::global().register(target.as_dyn());
let metrics = Arc::new(RecordingMetrics {
counters: std::sync::Mutex::new(Vec::new()),
});
let bus = RuntimeBus::new(
Arc::new(InMemoryRouteRepository::default()),
Arc::new(InMemoryProjectionStore::default()),
Arc::new(InMemoryEventPublisher::default()),
Arc::new(InMemoryCommandDedup::default()),
)
.with_metrics(Arc::clone(&metrics) as Arc<dyn MetricsCollector>);
let result = bus
.execute(RuntimeCommand::ReloadTemplates {
route_id: route.to_string(),
command_id: "tpl-metrics-1".to_string(),
causation_id: None,
})
.await;
assert!(result.is_ok(), "reload should succeed: {result:?}");
let recorded = metrics
.counters
.lock()
.unwrap()
.iter()
.cloned()
.collect::<Vec<_>>();
assert_eq!(
recorded.len(),
1,
"exactly one counter must be recorded: {recorded:?}"
);
assert_eq!(recorded[0].name, "template_reloads_total");
assert_eq!(recorded[0].value, 1.0);
assert_eq!(
recorded[0].labels,
vec![("route_id".to_string(), route.to_string())],
"label must include route_id: {:?}",
recorded[0].labels
);
}