use std::collections::HashMap;
use std::pin::Pin;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use tokio::sync::mpsc;
use tokio::time::{Duration, timeout};
use tower::{Service, ServiceExt};
use camel_api::error_handler::ErrorHandlerConfig;
use camel_api::unit_of_work::UnitOfWorkConfig;
use camel_api::{
AggregatorConfig, BoxProcessor, BoxProcessorExt, CamelError, Exchange, IdentityProcessor,
Message, OpaqueProcessor, RouteController, Value, ValueSourceDef,
};
use camel_component_api::{
Component, Consumer, ConsumerContext, Endpoint, ProducerContext, RuntimeObservability,
};
use crate::lifecycle::application::route_definition::{BuilderStep, RouteDefinition};
use crate::shared::components::domain::Registry;
use super::DefaultRouteController;
type CapturedCtxs = Arc<Mutex<Vec<ConsumerContext>>>;
struct CaptureComponent {
captured: CapturedCtxs,
}
struct CaptureEndpoint {
captured: CapturedCtxs,
}
struct CaptureConsumer {
captured: CapturedCtxs,
}
impl Component for CaptureComponent {
fn scheme(&self) -> &str {
"capture"
}
fn create_endpoint(
&self,
_uri: &str,
_ctx: &dyn camel_component_api::ComponentContext,
) -> Result<Box<dyn Endpoint>, CamelError> {
Ok(Box::new(CaptureEndpoint {
captured: Arc::clone(&self.captured),
}))
}
}
impl Endpoint for CaptureEndpoint {
fn uri(&self) -> &str {
"capture"
}
fn create_consumer(
&self,
_rt: Arc<dyn RuntimeObservability>,
) -> Result<Box<dyn Consumer>, CamelError> {
Ok(Box::new(CaptureConsumer {
captured: Arc::clone(&self.captured),
}))
}
fn create_producer(
&self,
_rt: Arc<dyn RuntimeObservability>,
_ctx: &ProducerContext,
) -> Result<BoxProcessor, CamelError> {
Ok(BoxProcessor::new(IdentityProcessor))
}
}
#[async_trait::async_trait]
impl Consumer for CaptureConsumer {
async fn start(&mut self, ctx: ConsumerContext) -> Result<(), CamelError> {
self.captured
.lock()
.expect("captured lock")
.push(ctx.clone());
ctx.cancel_token().cancelled().await;
Ok(())
}
async fn stop(&mut self) -> Result<(), CamelError> {
Ok(())
}
}
struct ProbeParts {
entered_rx: mpsc::UnboundedReceiver<()>,
release_tx: tokio::sync::watch::Sender<u32>,
}
fn gated_processor() -> (BoxProcessor, ProbeParts) {
let (entered_tx, entered_rx) = mpsc::unbounded_channel::<()>();
let (release_tx, release_rx) = tokio::sync::watch::channel(0u32);
let ordinal = Arc::new(AtomicU32::new(0));
let processor = BoxProcessor::from_fn(move |mut ex: Exchange| {
let entered_tx = entered_tx.clone();
let mut release_rx = release_rx.clone();
let ordinal = Arc::clone(&ordinal);
async move {
let _ = entered_tx.send(());
let mine = ordinal.fetch_add(1, Ordering::SeqCst) + 1;
release_rx
.wait_for(|v| *v >= mine)
.await
.expect("release channel alive");
ex.set_property("sink", "gated");
Ok(ex)
}
});
(
processor,
ProbeParts {
entered_rx,
release_tx,
},
)
}
#[derive(Clone)]
struct NeverReady;
impl Service<Exchange> for NeverReady {
type Response = Exchange;
type Error = CamelError;
type Future = Pin<Box<dyn std::future::Future<Output = Result<Exchange, CamelError>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
Poll::Ready(Err(CamelError::ProcessorError(
"planned readiness failure".into(),
)))
}
fn call(&mut self, ex: Exchange) -> Self::Future {
Box::pin(async move { Ok(ex) })
}
}
fn drain_controller(captured: CapturedCtxs) -> DefaultRouteController {
let registry = Arc::new(Mutex::new(Registry::new()));
registry
.lock()
.expect("registry lock")
.register(Arc::new(CaptureComponent { captured }));
DefaultRouteController::new(
registry,
Arc::new(camel_api::NoopPlatformService::default()),
)
}
async fn await_capture(captured: &CapturedCtxs) -> ConsumerContext {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while tokio::time::Instant::now() < deadline {
if let Some(ctx) = captured.lock().expect("captured lock").first().cloned() {
return ctx;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("consumer context was not captured within 2s");
}
async fn await_total(counter: &AtomicU64, want: u64) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while counter.load(Ordering::SeqCst) != want {
assert!(
tokio::time::Instant::now() < deadline,
"global in-flight counter did not reach {want} within 2s (now {})",
counter.load(Ordering::SeqCst)
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
async fn await_ctx_total(ctx: &crate::CamelContext, want: u64) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while ctx.total_in_flight() != want {
assert!(
tokio::time::Instant::now() < deadline,
"total_in_flight did not reach {want} within 2s (now {})",
ctx.total_in_flight()
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn test_exchange(tag: &str) -> Exchange {
Exchange::new(Message::new(tag))
}
struct RecordingPost {
tx: mpsc::UnboundedSender<String>,
}
impl Clone for RecordingPost {
fn clone(&self) -> Self {
Self {
tx: self.tx.clone(),
}
}
}
impl Service<Exchange> for RecordingPost {
type Response = Exchange;
type Error = CamelError;
type Future = Pin<Box<dyn std::future::Future<Output = Result<Exchange, CamelError>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, exchange: Exchange) -> Self::Future {
let body = exchange
.input
.body
.as_text()
.unwrap_or_default()
.to_string();
let _ = self.tx.send(body);
Box::pin(async move { Ok(exchange) })
}
}
#[tokio::test]
async fn pipeline_residency_counted_until_completion() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
let (processor, mut parts) = gated_processor();
let route = RouteDefinition::new(
"capture:src",
vec![BuilderStep::Processor(OpaqueProcessor(processor))],
)
.with_route_id("rt-drain-residency");
controller.add_route(route).await.unwrap();
controller.start_route("rt-drain-residency").await.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let result = ctx.send_and_wait(test_exchange("residency")).await;
let _ = done_tx.send(result);
});
timeout(Duration::from_secs(2), parts.entered_rx.recv())
.await
.expect("pipeline entry within 2s")
.expect("entry channel alive");
assert!(
controller.in_flight_total.load(Ordering::SeqCst) >= 1,
"exchange parked in the pipeline must be counted"
);
parts.release_tx.send(1).expect("release channel alive");
let result = timeout(Duration::from_secs(2), done_rx)
.await
.expect("reply within 2s")
.expect("reply channel alive");
assert!(result.is_ok(), "gated pipeline must complete: {result:?}");
await_total(&controller.in_flight_total, 0).await;
controller.stop_route("rt-drain-residency").await.unwrap();
}
#[tokio::test]
async fn readiness_failure_releases_claim() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
let route = RouteDefinition::new(
"capture:src",
vec![BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
NeverReady,
)))],
)
.with_route_id("rt-drain-readyfail");
controller.add_route(route).await.unwrap();
controller.start_route("rt-drain-readyfail").await.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let result = timeout(
Duration::from_secs(2),
ctx.send_and_wait(test_exchange("ready")),
)
.await
.expect("readiness failure must surface as a reply within 2s");
assert!(result.is_err(), "readiness failure must fail the exchange");
await_total(&controller.in_flight_total, 0).await;
controller.stop_route("rt-drain-readyfail").await.unwrap();
}
#[tokio::test]
async fn seda_enqueue_through_real_context_counted() {
let mut ctx = crate::CamelContext::builder().build().await.unwrap();
ctx.register_component(camel_component_seda::SedaComponent::new());
let (processor, mut parts) = gated_processor();
ctx.add_route_definition(
RouteDefinition::new(
"seda:a",
vec![BuilderStep::Processor(OpaqueProcessor(processor))],
)
.with_route_id("rt-seda-drain"),
)
.await
.unwrap();
ctx.start().await.unwrap();
let component = ctx.registry().get("seda").unwrap();
let endpoint = component.create_endpoint("seda:a", &ctx).unwrap();
let producer = endpoint
.create_producer(
Arc::new(camel_component_api::NoOpComponentContext),
&ctx.producer_context(),
)
.unwrap();
producer
.clone()
.oneshot(test_exchange("e2e"))
.await
.expect("seda enqueue accepted");
timeout(Duration::from_secs(2), parts.entered_rx.recv())
.await
.expect("pipeline entry within 2s")
.expect("entry channel alive");
assert!(
ctx.total_in_flight() >= 1,
"exchange parked in the seda-fed pipeline must be counted"
);
parts.release_tx.send(1).expect("release channel alive");
await_ctx_total(&ctx, 0).await;
ctx.stop().await.unwrap();
}
#[tokio::test]
async fn pending_bucket_keeps_claim() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
let agg_config = AggregatorConfig::correlate_by("key")
.complete_on_size_or_timeout(2, Duration::from_secs(600))
.build()
.unwrap();
let route = RouteDefinition::new(
"capture:src",
vec![
BuilderStep::DeclarativeSetHeader {
key: "key".into(),
value: ValueSourceDef::Literal(Value::String("k1".into())),
},
BuilderStep::Aggregate { config: agg_config },
],
)
.with_route_id("rt-drain-agg-pending");
controller.add_route(route).await.unwrap();
controller
.start_route("rt-drain-agg-pending")
.await
.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let first = ctx
.send_and_wait(test_exchange("frag-1"))
.await
.expect("pending ack reply");
assert!(
first.property("CamelAggregatorPending").is_some(),
"first exchange must be stashed, not completed"
);
assert_eq!(
controller.in_flight_total.load(Ordering::SeqCst),
1,
"stashed exchange must stay counted"
);
let second = ctx
.send_and_wait(test_exchange("frag-2"))
.await
.expect("aggregated reply");
assert!(
second.property("CamelAggregatorPending").is_none(),
"second exchange must complete the bucket"
);
await_total(&controller.in_flight_total, 0).await;
controller.stop_route("rt-drain-agg-pending").await.unwrap();
}
#[tokio::test]
async fn resequencer_buffer_residency_counted_until_emission() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
crate::lifecycle::adapters::route_controller::tests::register_simple_language(&mut controller);
let (emitted_tx, mut emitted_rx) = mpsc::unbounded_channel::<String>();
let route = RouteDefinition::new(
"capture:src",
vec![
BuilderStep::Resequence {
policy_config: camel_api::ResequencePolicyConfig {
mode: camel_api::ResequenceMode::Batch {
correlation: "${header.id}".into(),
sort: "${header.id}".into(),
completion: camel_api::BatchCompletion::Size(2),
},
},
},
BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(RecordingPost {
tx: emitted_tx,
}))),
],
)
.with_route_id("rt-drain-reseq");
controller.add_route(route).await.unwrap();
controller.start_route("rt-drain-reseq").await.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let mut first = test_exchange("reseq-1");
first.input.set_header("id", "k");
let ack = ctx.send_and_wait(first).await.expect("resequencer ack");
assert_eq!(
ack.property("CamelResequencerAccepted")
.and_then(|v| v.as_bool()),
Some(true),
"first exchange must be accepted into the buffer"
);
await_total(&controller.in_flight_total, 1).await;
let mut second = test_exchange("reseq-2");
second.input.set_header("id", "k");
let _ = ctx.send_and_wait(second).await.expect("second ack");
let mut emitted = Vec::new();
emitted.push(emitted_rx.recv().await.expect("first emission"));
emitted.push(emitted_rx.recv().await.expect("second emission"));
emitted.sort();
assert_eq!(emitted, vec!["reseq-1", "reseq-2"]);
await_total(&controller.in_flight_total, 0).await;
controller.stop_route("rt-drain-reseq").await.unwrap();
}
#[tokio::test]
async fn embedded_aggregator_stash_counted_until_completion() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
let (emitted_tx, mut emitted_rx) = mpsc::unbounded_channel::<String>();
let agg_config = AggregatorConfig::correlate_by("key")
.complete_when_size(2)
.build()
.unwrap();
let route = RouteDefinition::new(
"capture:src",
vec![
BuilderStep::DeclarativeSetHeader {
key: "key".into(),
value: ValueSourceDef::Literal(Value::String("k1".into())),
},
BuilderStep::Aggregate { config: agg_config },
BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(RecordingPost {
tx: emitted_tx,
}))),
],
)
.with_route_id("rt-drain-agg-embedded");
controller.add_route(route).await.unwrap();
controller
.start_route("rt-drain-agg-embedded")
.await
.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let first = ctx
.send_and_wait(test_exchange("frag-1"))
.await
.expect("pending ack reply");
assert!(
first.property("CamelAggregatorPending").is_some(),
"first exchange must be stashed, not completed"
);
await_total(&controller.in_flight_total, 1).await;
let second = ctx
.send_and_wait(test_exchange("frag-2"))
.await
.expect("aggregated reply");
assert!(
second.property("CamelAggregatorPending").is_none(),
"second exchange must complete the bucket"
);
let _ = emitted_rx.recv().await.expect("aggregated emission");
await_total(&controller.in_flight_total, 0).await;
controller
.stop_route("rt-drain-agg-embedded")
.await
.unwrap();
}
#[tokio::test]
async fn force_complete_releases_claims() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
let agg_config = AggregatorConfig::correlate_by("key")
.complete_when_size(10)
.force_completion_on_stop(true)
.build()
.unwrap();
let route = RouteDefinition::new(
"capture:src",
vec![
BuilderStep::DeclarativeSetHeader {
key: "key".into(),
value: ValueSourceDef::Literal(Value::String("k1".into())),
},
BuilderStep::Aggregate { config: agg_config },
],
)
.with_route_id("rt-drain-agg-force");
controller.add_route(route).await.unwrap();
controller.start_route("rt-drain-agg-force").await.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let first = ctx
.send_and_wait(test_exchange("frag-1"))
.await
.expect("pending ack reply");
assert!(first.property("CamelAggregatorPending").is_some());
assert_eq!(
controller.in_flight_total.load(Ordering::SeqCst),
1,
"stashed exchange must stay counted"
);
controller.stop_route("rt-drain-agg-force").await.unwrap();
await_total(&controller.in_flight_total, 0).await;
}
#[tokio::test]
async fn aggregate_split_post_pipeline_stash_counted_until_completion() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
let (emitted_tx, mut emitted_rx) = mpsc::unbounded_channel::<String>();
let route = RouteDefinition::new(
"capture:src",
vec![
BuilderStep::DeclarativeSetHeader {
key: "key".into(),
value: ValueSourceDef::Literal(Value::String("k1".into())),
},
BuilderStep::Aggregate {
config: AggregatorConfig::correlate_by("key")
.complete_on_size_or_timeout(2, Duration::from_secs(600))
.build()
.unwrap(),
},
BuilderStep::DeclarativeSetHeader {
key: "key".into(),
value: ValueSourceDef::Literal(Value::String("k1".into())),
},
BuilderStep::Aggregate {
config: AggregatorConfig::correlate_by("key")
.complete_when_size(2)
.build()
.unwrap(),
},
BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(RecordingPost {
tx: emitted_tx,
}))),
],
)
.with_route_id("rt-aggrloop-post-stash");
controller.add_route(route).await.unwrap();
controller
.start_route("rt-aggrloop-post-stash")
.await
.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let reply1 = ctx
.send_and_wait(test_exchange("frag-1"))
.await
.expect("pending ack reply");
assert!(
reply1.property("CamelAggregatorPending").is_some(),
"first exchange must be stashed in the route bucket"
);
await_total(&controller.in_flight_total, 1).await;
let reply2 = ctx
.send_and_wait(test_exchange("frag-2"))
.await
.expect("embedded pending marker reply");
assert!(
reply2.property("CamelAggregatorPending").is_some(),
"second exchange must be stashed in the embedded post-pipeline aggregator"
);
await_total(&controller.in_flight_total, 1).await;
let reply3 = ctx
.send_and_wait(test_exchange("frag-3"))
.await
.expect("pending ack reply");
assert!(
reply3.property("CamelAggregatorPending").is_some(),
"third exchange must be stashed in a new route bucket"
);
await_total(&controller.in_flight_total, 2).await;
let reply4 = ctx
.send_and_wait(test_exchange("frag-4"))
.await
.expect("final aggregate reply");
assert!(
reply4.property("CamelAggregatorPending").is_none(),
"fourth exchange must complete the embedded bucket"
);
let _ = emitted_rx.try_recv().expect("ex2 pending marker recording");
let _ = emitted_rx
.try_recv()
.expect("ex4 final aggregate recording");
assert!(
matches!(emitted_rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)),
"no third recording expected"
);
await_total(&controller.in_flight_total, 0).await;
controller
.stop_route("rt-aggrloop-post-stash")
.await
.unwrap();
}
#[tokio::test]
async fn aggregate_split_forced_emission_sibling_released_after_stop() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
let (emitted_tx, mut emitted_rx) = mpsc::unbounded_channel::<String>();
let route = RouteDefinition::new(
"capture:src",
vec![
BuilderStep::DeclarativeSetHeader {
key: "key".into(),
value: ValueSourceDef::Literal(Value::String("k1".into())),
},
BuilderStep::Aggregate {
config: AggregatorConfig::correlate_by("key")
.complete_when_size(10)
.force_completion_on_stop(true)
.build()
.unwrap(),
},
BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(RecordingPost {
tx: emitted_tx,
}))),
],
)
.with_route_id("rt-aggrloop-force-sib");
controller.add_route(route).await.unwrap();
controller
.start_route("rt-aggrloop-force-sib")
.await
.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let first = ctx
.send_and_wait(test_exchange("frag-1"))
.await
.expect("pending ack reply");
assert!(first.property("CamelAggregatorPending").is_some());
await_total(&controller.in_flight_total, 1).await;
controller
.stop_route("rt-aggrloop-force-sib")
.await
.unwrap();
let _body = timeout(Duration::from_secs(2), emitted_rx.recv())
.await
.expect("forced emission within 2s")
.expect("emission channel alive");
await_total(&controller.in_flight_total, 0).await;
}
#[tokio::test]
async fn abort_releases_claim() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
let (processor, mut parts) = gated_processor();
let route = RouteDefinition::new(
"capture:src",
vec![BuilderStep::Processor(OpaqueProcessor(processor))],
)
.with_route_id("rt-drain-abort");
controller.add_route(route).await.unwrap();
controller.start_route("rt-drain-abort").await.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let result = ctx.send_and_wait(test_exchange("abort")).await;
let _ = done_tx.send(result);
});
timeout(Duration::from_secs(2), parts.entered_rx.recv())
.await
.expect("pipeline entry within 2s")
.expect("entry channel alive");
await_total(&controller.in_flight_total, 2).await;
controller.stop_route("rt-drain-abort").await.unwrap();
await_total(&controller.in_flight_total, 0).await;
let result = timeout(Duration::from_secs(2), done_rx)
.await
.expect("reply resolution within 2s")
.expect("reply channel alive");
assert!(
result.is_err(),
"aborted pipeline must fail the waiter: {result:?}"
);
}
#[tokio::test]
async fn queued_envelope_drop_releases_claim() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
let (processor, mut parts) = gated_processor();
let route = RouteDefinition::new(
"capture:src",
vec![BuilderStep::Processor(OpaqueProcessor(processor))],
)
.with_route_id("rt-drain-queued");
controller.add_route(route).await.unwrap();
controller.start_route("rt-drain-queued").await.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
let waiter_ctx = ctx.clone();
tokio::spawn(async move {
let result = waiter_ctx.send_and_wait(test_exchange("parked")).await;
let _ = done_tx.send(result);
});
timeout(Duration::from_secs(2), parts.entered_rx.recv())
.await
.expect("pipeline entry within 2s")
.expect("entry channel alive");
await_total(&controller.in_flight_total, 2).await;
ctx.send(test_exchange("queued-2"))
.await
.expect("queued send");
ctx.send(test_exchange("queued-3"))
.await
.expect("queued send");
await_total(&controller.in_flight_total, 4).await;
controller.stop_route("rt-drain-queued").await.unwrap();
await_total(&controller.in_flight_total, 0).await;
let result = timeout(Duration::from_secs(2), done_rx)
.await
.expect("reply resolution within 2s")
.expect("reply channel alive");
assert!(
result.is_err(),
"aborted pipeline must fail the parked waiter: {result:?}"
);
}
#[tokio::test]
async fn panic_releases_claim() {
let captured: CapturedCtxs = Arc::new(Mutex::new(Vec::new()));
let mut controller = drain_controller(Arc::clone(&captured));
let route = RouteDefinition::new(
"capture:src",
vec![BuilderStep::Processor(OpaqueProcessor(
BoxProcessor::from_fn(|_ex: Exchange| async move {
panic!("planned pipeline panic");
}),
))],
)
.with_route_id("rt-drain-panic");
controller.add_route(route).await.unwrap();
controller.start_route("rt-drain-panic").await.unwrap();
controller.activate_cohort();
let ctx = await_capture(&captured).await;
let result = timeout(
Duration::from_secs(2),
ctx.send_and_wait(test_exchange("boom")),
)
.await
.expect("panic must resolve the waiter within 2s");
assert!(
result.is_err(),
"panicking pipeline must fail the exchange: {result:?}"
);
await_total(&controller.in_flight_total, 0).await;
controller.stop_route("rt-drain-panic").await.unwrap();
}
type SpyFlags = Arc<Mutex<HashMap<String, bool>>>;
struct SpyComponent {
flags: SpyFlags,
}
struct SpyEndpoint {
flags: SpyFlags,
base: String,
}
impl Component for SpyComponent {
fn scheme(&self) -> &str {
"spy"
}
fn create_endpoint(
&self,
uri: &str,
_ctx: &dyn camel_component_api::ComponentContext,
) -> Result<Box<dyn Endpoint>, CamelError> {
let base = uri
.strip_prefix("spy:")
.unwrap_or(uri)
.split('?')
.next()
.unwrap_or(uri)
.to_string();
Ok(Box::new(SpyEndpoint {
flags: Arc::clone(&self.flags),
base,
}))
}
}
impl Endpoint for SpyEndpoint {
fn uri(&self) -> &str {
"spy"
}
fn create_consumer(
&self,
_rt: Arc<dyn RuntimeObservability>,
) -> Result<Box<dyn Consumer>, CamelError> {
Err(CamelError::ComponentNotFound("spy has no consumer".into()))
}
fn create_producer(
&self,
rt: Arc<dyn RuntimeObservability>,
_ctx: &ProducerContext,
) -> Result<BoxProcessor, CamelError> {
self.flags
.lock()
.expect("flags lock")
.insert(self.base.clone(), rt.in_flight_counter().is_some());
Ok(BoxProcessor::new(IdentityProcessor))
}
}
#[tokio::test]
async fn producer_path_receives_counter_spy() {
let flags: SpyFlags = Arc::new(Mutex::new(HashMap::new()));
let mut ctx = crate::CamelContext::builder().build().await.unwrap();
ctx.register_component(SpyComponent {
flags: Arc::clone(&flags),
});
ctx.register_component(camel_component_direct::DirectComponent::new());
ctx.add_route_definition(
RouteDefinition::new("direct:spya", vec![BuilderStep::To("spy:plain".into())])
.with_route_id("rt-spy-plain"),
)
.await
.unwrap();
ctx.add_route_definition(
RouteDefinition::new("direct:spyb", vec![])
.with_route_id("rt-spy-dlc")
.with_error_handler(ErrorHandlerConfig {
dlc_uri: Some("spy:dlc".into()),
policies: vec![],
use_original_message: false,
}),
)
.await
.unwrap();
ctx.add_route_definition(
RouteDefinition::new("direct:spyd", vec![])
.with_route_id("rt-spy-ctl")
.with_unit_of_work(UnitOfWorkConfig {
on_complete: Some("spy:ctl".into()),
on_failure: None,
}),
)
.await
.unwrap();
ctx.runtime_execution_handle()
.compile_route_definition(
RouteDefinition::new("direct:spyc", vec![])
.with_route_id("rt-spy-uow")
.with_unit_of_work(UnitOfWorkConfig {
on_complete: Some("spy:uow".into()),
on_failure: None,
}),
)
.await
.expect("compile spy UoW route");
let snapshot = flags.lock().expect("flags lock").clone();
for key in ["plain", "dlc", "ctl", "uow"] {
assert_eq!(
snapshot.get(key),
Some(&true),
"producer for spy:{key} must see the in-flight counter — a production ControllerComponentContext site was missed"
);
}
}