use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context as TaskContext, Poll};
use std::time::Duration;
use async_trait::async_trait;
use tokio::sync::{oneshot, watch};
use tower::Service;
use camel_api::{
BoxProcessor, CamelError, Exchange, Message, OpaqueProcessor, RuntimeCommand,
RuntimeCommandResult,
};
use camel_component_api::{
Component, ComponentContext, Consumer, ConsumerContext, ConsumerStartupMode, Endpoint,
ProducerContext, RuntimeObservability,
};
use crate::context::{CamelContext, RuntimeExecutionHandle};
use crate::lifecycle::application::route_definition::{BuilderStep, RouteDefinition};
const ROUTE_A: &str = "cohort-regression-a";
const ROUTE_B: &str = "cohort-regression-b";
struct HeldExplicitConsumer {
entered_tx: watch::Sender<bool>,
hold_rx: watch::Receiver<bool>,
}
#[async_trait]
impl Consumer for HeldExplicitConsumer {
async fn start(&mut self, ctx: ConsumerContext) -> Result<(), CamelError> {
let _ = self.entered_tx.send(true);
while !*self.hold_rx.borrow_and_update() {
if self.hold_rx.changed().await.is_err() {
break;
}
}
ctx.mark_ready();
ctx.cancelled().await;
Ok(())
}
async fn stop(&mut self) -> Result<(), CamelError> {
Ok(())
}
fn startup_mode(&self) -> ConsumerStartupMode {
ConsumerStartupMode::Explicit
}
}
struct HeldExplicitEndpoint {
entered_tx: watch::Sender<bool>,
hold_rx: watch::Receiver<bool>,
}
impl Endpoint for HeldExplicitEndpoint {
fn uri(&self) -> &str {
"heldexplicit:bind"
}
fn create_consumer(
&self,
_rt: Arc<dyn RuntimeObservability>,
) -> Result<Box<dyn Consumer>, CamelError> {
Ok(Box::new(HeldExplicitConsumer {
entered_tx: self.entered_tx.clone(),
hold_rx: self.hold_rx.clone(),
}))
}
fn create_producer(
&self,
_rt: Arc<dyn RuntimeObservability>,
_ctx: &ProducerContext,
) -> Result<BoxProcessor, CamelError> {
Err(CamelError::ProcessorError(
"heldexplicit does not support producers".into(),
))
}
}
struct HeldExplicitComponent {
entered_tx: watch::Sender<bool>,
hold_rx: watch::Receiver<bool>,
}
impl Component for HeldExplicitComponent {
fn scheme(&self) -> &str {
"heldexplicit"
}
fn create_endpoint(
&self,
_uri: &str,
_ctx: &dyn ComponentContext,
) -> Result<Box<dyn Endpoint>, CamelError> {
Ok(Box::new(HeldExplicitEndpoint {
entered_tx: self.entered_tx.clone(),
hold_rx: self.hold_rx.clone(),
}))
}
}
struct EmitOnceConsumer {
emitted_tx: watch::Sender<bool>,
}
#[async_trait]
impl Consumer for EmitOnceConsumer {
async fn start(&mut self, ctx: ConsumerContext) -> Result<(), CamelError> {
ctx.send(Exchange::new(Message::new("cohort-tick"))).await?;
let _ = self.emitted_tx.send(true);
ctx.cancelled().await;
Ok(())
}
async fn stop(&mut self) -> Result<(), CamelError> {
Ok(())
}
}
struct EmitOnceEndpoint {
emitted_tx: watch::Sender<bool>,
}
impl Endpoint for EmitOnceEndpoint {
fn uri(&self) -> &str {
"emitonce:tick"
}
fn create_consumer(
&self,
_rt: Arc<dyn RuntimeObservability>,
) -> Result<Box<dyn Consumer>, CamelError> {
Ok(Box::new(EmitOnceConsumer {
emitted_tx: self.emitted_tx.clone(),
}))
}
fn create_producer(
&self,
_rt: Arc<dyn RuntimeObservability>,
_ctx: &ProducerContext,
) -> Result<BoxProcessor, CamelError> {
Err(CamelError::ProcessorError(
"emitonce does not support producers".into(),
))
}
}
struct EmitOnceComponent {
emitted_tx: watch::Sender<bool>,
}
impl Component for EmitOnceComponent {
fn scheme(&self) -> &str {
"emitonce"
}
fn create_endpoint(
&self,
_uri: &str,
_ctx: &dyn ComponentContext,
) -> Result<Box<dyn Endpoint>, CamelError> {
Ok(Box::new(EmitOnceEndpoint {
emitted_tx: self.emitted_tx.clone(),
}))
}
}
type StopRouteOutcome = Result<RuntimeCommandResult, CamelError>;
#[derive(Clone)]
struct StopRouteStep {
exec: RuntimeExecutionHandle,
target: String,
dispatch_observed: Arc<AtomicBool>,
result_slot: Arc<Mutex<Option<StopRouteOutcome>>>,
}
impl Service<Exchange> for StopRouteStep {
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 TaskContext<'_>) -> Poll<Result<(), CamelError>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, exchange: Exchange) -> Self::Future {
let exec = self.exec.clone();
let target = self.target.clone();
let dispatch_observed = Arc::clone(&self.dispatch_observed);
let result_slot = Arc::clone(&self.result_slot);
Box::pin(async move {
dispatch_observed.store(true, Ordering::SeqCst);
let result = exec
.execute_runtime_command(RuntimeCommand::StopRoute {
route_id: target,
command_id: "cohort-regression-stop-b".to_string(),
causation_id: None,
})
.await;
*result_slot.lock().expect("result slot lock") = Some(result); Ok(exchange)
})
}
}
struct Boot {
start_rx: oneshot::Receiver<Result<(), CamelError>>,
done_tx: oneshot::Sender<()>,
join: tokio::task::JoinHandle<()>,
}
fn spawn_boot(mut ctx: CamelContext) -> Boot {
let (start_tx, start_rx) = oneshot::channel();
let (done_tx, done_rx) = oneshot::channel();
let join = tokio::spawn(async move {
let result = ctx.start().await;
let _ = start_tx.send(result);
let _ = done_rx.await;
let _ = ctx.stop().await;
});
Boot {
start_rx,
done_tx,
join,
}
}
struct CohortFixture {
hold_tx: watch::Sender<bool>,
entered_rx: watch::Receiver<bool>,
emitted_rx: watch::Receiver<bool>,
dispatch_observed: Arc<AtomicBool>,
result_slot: Arc<Mutex<Option<StopRouteOutcome>>>,
exec: RuntimeExecutionHandle,
boot: Boot,
}
async fn boot_held_cohort() -> CohortFixture {
let (hold_tx, hold_rx) = watch::channel(false);
let (entered_tx, entered_rx) = watch::channel(false);
let (emitted_tx, emitted_rx) = watch::channel(false);
let dispatch_observed = Arc::new(AtomicBool::new(false));
let result_slot: Arc<Mutex<Option<StopRouteOutcome>>> = Arc::new(Mutex::new(None));
let mut ctx = CamelContext::builder()
.build()
.await
.expect("build context"); ctx.register_component(HeldExplicitComponent {
entered_tx,
hold_rx,
});
ctx.register_component(EmitOnceComponent { emitted_tx });
let exec = ctx.runtime_execution_handle();
let stop_step = StopRouteStep {
exec: exec.clone(),
target: ROUTE_B.to_string(),
dispatch_observed: Arc::clone(&dispatch_observed),
result_slot: Arc::clone(&result_slot),
};
ctx.add_route_definition(
RouteDefinition::new(
"emitonce:tick",
vec![BuilderStep::Processor(OpaqueProcessor(BoxProcessor::new(
stop_step,
)))],
)
.with_route_id(ROUTE_A)
.with_startup_order(0),
)
.await
.expect("add route A"); ctx.add_route_definition(
RouteDefinition::new("heldexplicit:bind", vec![])
.with_route_id(ROUTE_B)
.with_startup_order(1),
)
.await
.expect("add route B");
let boot = spawn_boot(ctx);
CohortFixture {
hold_tx,
entered_rx,
emitted_rx,
dispatch_observed,
result_slot,
exec,
boot,
}
}
async fn await_true(rx: &mut watch::Receiver<bool>, what: &'static str) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while !*rx.borrow_and_update() {
tokio::time::timeout_at(deadline, rx.changed())
.await
.unwrap_or_else(|_| panic!("{what} must fire within 5s"))
.unwrap_or_else(|_| panic!("watch sender must stay alive for {what}"));
}
}
async fn await_recorded(slot: &Mutex<Option<StopRouteOutcome>>) -> StopRouteOutcome {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
if let Some(outcome) = slot.lock().expect("result slot lock").take() {
return outcome;
}
assert!(
tokio::time::Instant::now() < deadline,
"the step must record its StopRoute outcome within 5s"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
async fn await_route_status(exec: &RuntimeExecutionHandle, route_id: &str, want: &str) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let status = exec
.runtime_route_status(route_id)
.await
.expect("route status query"); if status.as_deref() == Some(want) {
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"route {route_id} must reach {want} within 5s (now {status:?})"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cohort_regression_parks_first_dispatch_until_cohort_completes() {
let CohortFixture {
hold_tx,
mut entered_rx,
mut emitted_rx,
dispatch_observed,
result_slot,
exec,
boot,
} = boot_held_cohort().await;
let Boot {
start_rx,
done_tx,
join,
} = boot;
await_true(&mut emitted_rx, "A must emit its first exchange").await;
await_true(&mut entered_rx, "B must enter start()").await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
!dispatch_observed.load(Ordering::SeqCst),
"first dispatch must park on the closed cohort gate while B's \
handshake is held — the exchange dispatched during the cohort"
);
hold_tx.send_replace(true);
let start_result = tokio::time::timeout(Duration::from_secs(5), start_rx)
.await
.expect("boot start report must arrive within 5s") .expect("boot task must deliver the start result"); start_result.expect("ctx.start() must return Ok once B's handshake is released");
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while !dispatch_observed.load(Ordering::SeqCst) {
assert!(
tokio::time::Instant::now() < deadline,
"the parked dispatch must run within 5s of cohort activation"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
match await_recorded(&result_slot).await {
Ok(_) => {}
Err(e) => panic!(
"StopRoute(B) after cohort completion must be Ok, got: {e} \
(ungated dispatch rejection resurfaced)"
),
}
await_route_status(&exec, ROUTE_B, "Stopped").await;
let _ = done_tx.send(());
tokio::time::timeout(Duration::from_secs(5), join)
.await
.expect("boot task must join within 5s of the done signal") .expect("boot task must not panic"); }
#[tokio::test(flavor = "multi_thread")]
async fn cohort_regression_ungated_simulation_shows_the_rejection() {
let CohortFixture {
hold_tx,
mut entered_rx,
mut emitted_rx,
result_slot,
exec,
boot,
..
} = boot_held_cohort().await;
let Boot {
start_rx,
done_tx,
join,
} = boot;
await_true(&mut emitted_rx, "A must emit its first exchange").await;
await_true(&mut entered_rx, "B must enter start()").await;
exec.controller.cohort_gate().open();
let recorded = await_recorded(&result_slot).await;
match recorded {
Err(e) => assert!(
e.to_string().contains("invalid transition"),
"ungated dispatch must reproduce the invalid-transition rejection \
class (B is Starting during the hold), got: {e}"
),
Ok(res) => panic!("ungated StopRoute(B) during the hold must be rejected, got Ok: {res:?}"),
}
hold_tx.send_replace(true);
let start_result = tokio::time::timeout(Duration::from_secs(5), start_rx)
.await
.expect("ungated boot start report must arrive within 5s") .expect("boot task must deliver the start result"); start_result.expect("ctx.start() must return Ok once B's handshake is released"); let _ = done_tx.send(());
tokio::time::timeout(Duration::from_secs(5), join)
.await
.expect("boot task must join within 5s of the done signal") .expect("boot task must not panic"); }