use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use camel_api::circuit_breaker::CircuitBreakerConfig;
use camel_api::{
BoxProcessor, BoxProcessorExt, CamelError, Exchange, Message, OpaqueProcessor, StepLifecycle,
StepShutdownReason,
};
use camel_component_api::{Component, Consumer, Endpoint};
use camel_component_direct::DirectComponent;
use camel_core::route::BuilderStep;
use camel_core::{CamelContext, RouteDefinition};
use tower::ServiceExt;
fn test_rt() -> Arc<dyn camel_component_api::RuntimeObservability> {
Arc::new(camel_component_api::NoOpComponentContext)
}
fn failing_step(msg: &'static str) -> BoxProcessor {
BoxProcessor::from_fn(move |_ex| {
Box::pin(async move { Err(CamelError::ProcessorError(msg.into())) })
})
}
#[derive(Debug)]
struct BlockerState {
entered: AtomicBool,
delivered: AtomicBool,
started: AtomicBool,
shutdown_entered: AtomicBool,
shutdown_done: AtomicBool,
producer_release: tokio::sync::Notify,
lifecycle_release: tokio::sync::Notify,
}
impl BlockerState {
fn new() -> Self {
Self {
entered: AtomicBool::new(false),
delivered: AtomicBool::new(false),
started: AtomicBool::new(false),
shutdown_entered: AtomicBool::new(false),
shutdown_done: AtomicBool::new(false),
producer_release: tokio::sync::Notify::new(),
lifecycle_release: tokio::sync::Notify::new(),
}
}
}
#[derive(Debug)]
struct BlockerLifecycle(Arc<BlockerState>);
#[async_trait]
impl StepLifecycle for BlockerLifecycle {
fn name(&self) -> &'static str {
"blocker"
}
async fn start(&self) -> Result<(), CamelError> {
self.0.started.store(true, Ordering::SeqCst);
Ok(())
}
async fn shutdown(&self, _reason: StepShutdownReason) -> Result<(), CamelError> {
self.0.shutdown_entered.store(true, Ordering::SeqCst);
self.0.lifecycle_release.notified().await;
self.0.shutdown_done.store(true, Ordering::SeqCst);
Ok(())
}
}
struct BlockingEndpoint {
state: Arc<BlockerState>,
}
impl Endpoint for BlockingEndpoint {
fn uri(&self) -> &str {
"blocker:tap"
}
fn create_consumer(
&self,
_rt: Arc<dyn camel_component_api::RuntimeObservability>,
) -> Result<Box<dyn Consumer>, CamelError> {
Err(CamelError::RouteError("blocker has no consumer".into()))
}
fn create_producer(
&self,
_rt: Arc<dyn camel_component_api::RuntimeObservability>,
_ctx: &camel_api::ProducerContext,
) -> Result<BoxProcessor, CamelError> {
let state = Arc::clone(&self.state);
Ok(BoxProcessor::from_fn(move |ex| {
let state = Arc::clone(&state);
Box::pin(async move {
state.entered.store(true, Ordering::SeqCst);
state.producer_release.notified().await;
state.delivered.store(true, Ordering::SeqCst);
Ok(ex)
})
}))
}
fn lifecycle(&self) -> Option<Arc<dyn StepLifecycle>> {
Some(Arc::new(BlockerLifecycle(Arc::clone(&self.state))))
}
}
struct BlockingComponent {
state: Arc<BlockerState>,
}
impl Component for BlockingComponent {
fn scheme(&self) -> &str {
"blocker"
}
fn create_endpoint(
&self,
_uri: &str,
_ctx: &dyn camel_component_api::ComponentContext,
) -> Result<Box<dyn Endpoint>, CamelError> {
Ok(Box::new(BlockingEndpoint {
state: Arc::clone(&self.state),
}))
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stateful_fallback_step_lifecycle_invoked() {
let state = Arc::new(BlockerState::new());
let def = RouteDefinition::new(
"direct:cb",
vec![BuilderStep::Processor(OpaqueProcessor(failing_step(
"cb-lifecycle upstream failure",
)))],
)
.with_route_id("cb-fallback-lifecycle")
.with_circuit_breaker(
CircuitBreakerConfig::new()
.failure_threshold(1)
.open_duration(Duration::from_secs(60)),
)
.with_circuit_breaker_fallback(vec![BuilderStep::To("blocker:tap".into())]);
let mut ctx = CamelContext::builder().build().await.unwrap();
ctx.register_component(DirectComponent::new());
ctx.register_component(BlockingComponent {
state: Arc::clone(&state),
});
ctx.add_route_definition(def)
.await
.expect("route must compile");
ctx.start().await.expect("context start failed");
assert!(
state.started.load(Ordering::SeqCst),
"fallback StepLifecycle::start was never invoked — \
fallback lifecycle handles were not merged into the route lifecycle"
);
{
let component = ctx.registry().get("direct").expect("direct registered");
let producer_ctx = ctx.producer_context();
let endpoint = component
.create_endpoint("direct:cb", &ctx)
.expect("create endpoint");
let producer = endpoint
.create_producer(test_rt(), &producer_ctx)
.expect("create producer");
match producer.oneshot(Exchange::new(Message::new("first"))).await {
Err(e) => assert!(
!matches!(e, CamelError::CircuitOpen(_)),
"first exchange must fail upstream, not on an open circuit: {e}"
),
Ok(_) => panic!("first exchange must fail and open the circuit"),
}
}
let producer = {
let component = ctx.registry().get("direct").expect("direct registered");
let producer_ctx = ctx.producer_context();
let endpoint = component
.create_endpoint("direct:cb", &ctx)
.expect("create endpoint");
endpoint
.create_producer(test_rt(), &producer_ctx)
.expect("create producer")
};
let in_flight = tokio::spawn(async move {
let _ = producer
.oneshot(Exchange::new(Message::new("second")))
.await;
});
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while !state.entered.load(Ordering::SeqCst) {
assert!(
tokio::time::Instant::now() < deadline,
"fallback producer never entered — fallback was not compiled/executed"
);
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(
!state.delivered.load(Ordering::SeqCst),
"delivery must not be recorded while the producer is blocked"
);
let stopper = tokio::spawn(async move {
ctx.stop().await.expect("context stop failed");
});
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(
!stopper.is_finished(),
"shutdown completed while a fallback exchange was still in flight — \
fallback drain was skipped"
);
state.producer_release.notify_one();
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while !state.shutdown_entered.load(Ordering::SeqCst) {
assert!(
tokio::time::Instant::now() < deadline,
"fallback StepLifecycle::shutdown was never invoked — \
fallback lifecycle handles were not merged into the route lifecycle"
);
tokio::time::sleep(Duration::from_millis(5)).await;
}
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(
!stopper.is_finished(),
"shutdown completed while the fallback StepLifecycle::shutdown was \
still blocked — stop does not await fallback step shutdown"
);
state.lifecycle_release.notify_one();
tokio::time::timeout(Duration::from_secs(5), stopper)
.await
.expect("shutdown must complete after the fallback lifecycle drains")
.expect("stop task must not panic");
assert!(
state.delivered.load(Ordering::SeqCst),
"fallback delivery must be recorded after release"
);
assert!(
state.shutdown_done.load(Ordering::SeqCst),
"fallback StepLifecycle::shutdown must complete after release"
);
let _ = in_flight.await;
}
#[tokio::test]
async fn fallback_without_circuit_breaker_fails_closed() {
let def = RouteDefinition::new(
"direct:cb",
vec![BuilderStep::Processor(OpaqueProcessor(failing_step(
"cb-no-config upstream",
)))],
)
.with_route_id("cb-fallback-no-config")
.with_circuit_breaker_fallback(vec![BuilderStep::To("blocker:tap".into())]);
let mut ctx = CamelContext::builder().build().await.unwrap();
ctx.register_component(DirectComponent::new());
ctx.register_component(BlockingComponent {
state: Arc::new(BlockerState::new()),
});
let err = ctx
.add_route_definition(def)
.await
.expect_err("fallback sidecar without circuit_breaker must fail closed");
let msg = err.to_string();
assert!(
msg.contains("circuit_breaker_fallback"),
"error must name circuit_breaker_fallback: {msg}"
);
}