use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tokio::task::JoinSet;
use camel_api::{
AggregationStrategy, Body, CamelError, Exchange, OutcomeSegment, PipelineOutcome, SplitSource,
Value,
};
use crate::splitter::{CAMEL_SPLIT_COMPLETE, CAMEL_SPLIT_INDEX, CAMEL_SPLIT_SIZE};
pub(crate) fn aggregate_completed(
completed: Vec<Exchange>,
original: Exchange,
strategy: AggregationStrategy,
) -> Exchange {
match strategy {
AggregationStrategy::LastWins => completed.into_iter().last().unwrap_or(original),
AggregationStrategy::CollectAll => {
let mut bodies = Vec::new();
for ex in &completed {
let value = match &ex.input.body {
Body::Text(s) => Value::String(s.clone()),
Body::Json(v) => v.clone(),
Body::Xml(s) => Value::String(s.clone()),
Body::Bytes(b) => Value::String(String::from_utf8_lossy(b).into_owned()),
Body::Stream(s) => serde_json::json!({
"_stream": {
"origin": s.metadata.origin,
"placeholder": true,
"hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
}
}),
_ => Value::Null,
};
bodies.push(value);
}
let mut out = original;
out.input.body = Body::Json(Value::Array(bodies));
out
}
AggregationStrategy::Custom(fold_fn) => {
let mut iter = completed.into_iter();
let first = iter.next().unwrap_or(original);
iter.fold(first, |acc, next| fold_fn(acc, next))
}
_ => original,
}
}
pub struct SplitSegment {
pub splitter: SplitSource,
pub body: OutcomeSegment,
pub parallel: bool,
pub parallel_limit: Option<usize>,
pub stop_on_exception: bool,
pub max_fragments: usize,
pub aggregation: AggregationStrategy,
}
impl Clone for SplitSegment {
fn clone(&self) -> Self {
Self {
splitter: self.splitter.clone(),
body: self.body.clone(),
parallel: self.parallel,
parallel_limit: self.parallel_limit,
stop_on_exception: self.stop_on_exception,
max_fragments: self.max_fragments,
aggregation: self.aggregation.clone(),
}
}
}
impl camel_api::OutcomePipeline for SplitSegment {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: camel_api::Exchange,
) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
let splitter = self.splitter.clone();
let aggregation = self.aggregation.clone();
let parallel = self.parallel;
let parallel_limit = self.parallel_limit;
let stop_on_exception = self.stop_on_exception;
let max_fragments = self.max_fragments;
let body = &mut self.body;
Box::pin(async move {
let original = exchange;
let mut fragments = match splitter.split(&original).await {
Ok(fragments) => fragments,
Err(err) => return PipelineOutcome::Failed(err),
};
if fragments.is_empty() {
return PipelineOutcome::Completed(original);
}
if fragments.len() > max_fragments {
return PipelineOutcome::Failed(CamelError::ProcessorError(format!(
"Splitter produced {} fragments, exceeding max_fragments {}",
fragments.len(),
max_fragments
)));
}
let total = fragments.len();
for (i, frag) in fragments.iter_mut().enumerate() {
frag.set_property(CAMEL_SPLIT_INDEX, Value::from(i as u64));
frag.set_property(CAMEL_SPLIT_SIZE, Value::from(total as u64));
frag.set_property(CAMEL_SPLIT_COMPLETE, Value::Bool(i == total - 1));
}
if parallel {
parallel_split(
fragments,
original,
body,
&aggregation,
parallel_limit,
stop_on_exception,
)
.await
} else {
sequential_split(fragments, original, body, &aggregation, stop_on_exception).await
}
})
}
}
async fn sequential_split(
fragments: Vec<Exchange>,
original: Exchange,
body: &mut OutcomeSegment,
aggregation: &AggregationStrategy,
stop_on_exception: bool,
) -> PipelineOutcome {
let mut outputs = Vec::new();
let mut last_error: Option<CamelError> = None;
for frag in fragments {
match body.run(frag).await {
PipelineOutcome::Completed(ex) => outputs.push(ex),
PipelineOutcome::Stopped(ex) => return PipelineOutcome::Stopped(ex),
PipelineOutcome::Failed(err) => {
if stop_on_exception {
return PipelineOutcome::Failed(err);
}
last_error = Some(err);
}
}
}
if let Some(err) = last_error {
return PipelineOutcome::Failed(err);
}
PipelineOutcome::Completed(aggregate_completed(outputs, original, aggregation.clone()))
}
async fn parallel_split(
fragments: Vec<Exchange>,
original: Exchange,
body: &mut OutcomeSegment,
aggregation: &AggregationStrategy,
parallel_limit: Option<usize>,
stop_on_exception: bool,
) -> PipelineOutcome {
use tokio::sync::Semaphore;
let stopped_seen = Arc::new(AtomicBool::new(false));
let stopped_idx = Arc::new(AtomicUsize::new(usize::MAX));
let aggregation = aggregation.clone();
let semaphore = parallel_limit
.filter(|&limit| limit > 0)
.map(|limit| Arc::new(Semaphore::new(limit)));
let mut set: JoinSet<(usize, Option<PipelineOutcome>)> = JoinSet::new();
for (idx, frag) in fragments.into_iter().enumerate() {
let mut body = body.clone();
let stopped_seen = Arc::clone(&stopped_seen);
let stopped_idx = Arc::clone(&stopped_idx);
let sem = semaphore.clone();
set.spawn(async move {
if stopped_seen.load(Ordering::SeqCst) {
return (idx, None);
}
let _permit: Option<tokio::sync::OwnedSemaphorePermit> = match &sem {
Some(s) => match std::sync::Arc::clone(s).acquire_owned().await {
Ok(p) => Some(p),
Err(_) => {
return (
idx,
Some(PipelineOutcome::Failed(CamelError::ProcessorError(
"semaphore closed".into(),
))),
);
}
},
None => None,
};
if stopped_seen.load(Ordering::SeqCst) {
return (idx, None);
}
let outcome = body.run(frag).await;
if let PipelineOutcome::Stopped(_) = &outcome {
loop {
let cur = stopped_idx.load(Ordering::SeqCst);
if idx >= cur {
break; }
match stopped_idx.compare_exchange_weak(
cur,
idx,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => break,
Err(actual) => {
if actual <= idx {
break;
}
}
}
}
stopped_seen.store(true, Ordering::SeqCst);
}
(idx, Some(outcome))
});
}
let mut results: Vec<(usize, PipelineOutcome)> = Vec::new();
while let Some(res) = set.join_next().await {
if let Ok((idx, Some(o))) = res {
results.push((idx, o));
}
}
if stopped_seen.load(Ordering::SeqCst) {
let winning_idx = stopped_idx.load(Ordering::SeqCst);
if winning_idx == usize::MAX {
tracing::warn!(
target: "camel.phase4.split",
"stopped_seen=true but stopped_idx=usize::MAX — race condition; falling back to pre-split exchange"
);
return PipelineOutcome::Stopped(original);
}
let stopped_ex = results
.iter()
.find(|(idx, _)| *idx == winning_idx)
.and_then(|(_, o)| match o {
PipelineOutcome::Stopped(ex) => Some(ex.branch_clone()),
_ => None,
});
if let Some(ex) = stopped_ex {
return PipelineOutcome::Stopped(ex);
}
tracing::warn!(
target: "camel.phase4.split",
winning_idx = winning_idx,
"winning_idx not found in results — falling back to pre-split exchange"
);
return PipelineOutcome::Stopped(original);
}
results.sort_by_key(|(idx, _)| *idx);
if stop_on_exception {
let mut first_failed: Option<(usize, CamelError)> = None;
for (idx, o) in &results {
if let PipelineOutcome::Failed(err) = o
&& first_failed
.as_ref()
.map(|(i, _)| *i > *idx)
.unwrap_or(true)
{
first_failed = Some((*idx, err.clone()));
}
}
if let Some((_, err)) = first_failed {
return PipelineOutcome::Failed(err);
}
} else {
let mut last_error: Option<CamelError> = None;
for (_, o) in &results {
if let PipelineOutcome::Failed(err) = o {
last_error = Some(err.clone());
}
}
if let Some(err) = last_error {
return PipelineOutcome::Failed(err);
}
}
let completed: Vec<Exchange> = results
.into_iter()
.filter_map(|(_, o)| match o {
PipelineOutcome::Completed(ex) => Some(ex),
_ => None,
})
.collect();
PipelineOutcome::Completed(aggregate_completed(completed, original, aggregation))
}
#[cfg(test)]
mod tests {
use super::*;
use camel_api::Message;
#[derive(Clone)]
#[allow(dead_code)]
struct CompletedBody;
impl camel_api::OutcomePipeline for CompletedBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(CompletedBody)
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
Box::pin(async move { PipelineOutcome::Completed(exchange) })
}
}
#[derive(Clone)]
#[allow(dead_code)]
struct StopBody;
impl camel_api::OutcomePipeline for StopBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(StopBody)
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
Box::pin(async move { PipelineOutcome::Stopped(exchange) })
}
}
#[derive(Clone)]
struct StopOnNthBody {
counter: Arc<AtomicUsize>,
stop_at: usize,
}
impl camel_api::OutcomePipeline for StopOnNthBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
let count = self.counter.fetch_add(1, Ordering::SeqCst);
let stop_at = self.stop_at;
Box::pin(async move {
if count >= stop_at {
PipelineOutcome::Stopped(exchange)
} else {
PipelineOutcome::Completed(exchange)
}
})
}
}
#[derive(Clone)]
struct MutateAndStopBody;
impl camel_api::OutcomePipeline for MutateAndStopBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(MutateAndStopBody)
}
fn run<'a>(
&'a mut self,
mut exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
Box::pin(async move {
exchange.input.body = Body::Text("mutated-by-body".to_string());
PipelineOutcome::Stopped(exchange)
})
}
}
#[tokio::test]
async fn stop_inside_split_sequential_halts_remaining_fragments() {
let invocations = Arc::new(AtomicUsize::new(0));
let body = StopOnNthBody {
counter: Arc::clone(&invocations),
stop_at: 1, };
let mut seg = SplitSegment {
splitter: camel_api::SplitSource::Sync(camel_api::split_body_lines()),
body: OutcomeSegment::new(Box::new(body)),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("a\nb\nc"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(matches!(result, PipelineOutcome::Stopped(_)));
assert_eq!(invocations.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn stop_inside_split_sequential_preserves_exchange_mutations() {
let mut seg = SplitSegment {
splitter: camel_api::SplitSource::Sync(camel_api::split_body_lines()),
body: OutcomeSegment::new(Box::new(MutateAndStopBody)),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("hello"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
match result {
PipelineOutcome::Stopped(ex) => {
assert_eq!(
ex.input.body.as_text(),
Some("mutated-by-body"),
"Stopped exchange should carry body mutation"
);
}
other => panic!("Expected Stopped, got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn stop_inside_split_parallel_cancels_pending_and_waits_inflight() {
use tokio::sync::Barrier;
let barrier = Arc::new(Barrier::new(3));
let fragment1_completed = Arc::new(AtomicBool::new(false));
let fragment2_completed = Arc::new(AtomicBool::new(false));
let frag1_ok = Arc::clone(&fragment1_completed);
let frag2_ok = Arc::clone(&fragment2_completed);
let bar = Arc::clone(&barrier);
let splitter = SplitSource::Sync(Arc::new(|ex: &Exchange| {
Ok((0..3)
.map(|i| {
let mut frag = ex.clone();
frag.input.body = Body::Text(format!("frag-{i}"));
frag
})
.collect())
}));
struct BarrierDispatchBody {
barrier: Arc<Barrier>,
f1_completed: Arc<AtomicBool>,
f2_completed: Arc<AtomicBool>,
}
impl Clone for BarrierDispatchBody {
fn clone(&self) -> Self {
Self {
barrier: Arc::clone(&self.barrier),
f1_completed: Arc::clone(&self.f1_completed),
f2_completed: Arc::clone(&self.f2_completed),
}
}
}
impl camel_api::OutcomePipeline for BarrierDispatchBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
let bar = Arc::clone(&self.barrier);
let f1c = Arc::clone(&self.f1_completed);
let f2c = Arc::clone(&self.f2_completed);
Box::pin(async move {
let body_text = exchange.input.body.as_text().unwrap_or("").to_string();
bar.wait().await;
match body_text.as_str() {
"frag-0" => PipelineOutcome::Stopped(exchange),
"frag-1" => {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
f1c.store(true, Ordering::SeqCst);
PipelineOutcome::Completed(exchange)
}
"frag-2" => {
f2c.store(true, Ordering::SeqCst);
PipelineOutcome::Completed(exchange)
}
_ => PipelineOutcome::Completed(exchange),
}
})
}
}
let body = BarrierDispatchBody {
barrier: bar,
f1_completed: frag1_ok,
f2_completed: frag2_ok,
};
let mut seg = SplitSegment {
splitter,
body: OutcomeSegment::new(Box::new(body)),
parallel: true,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("test"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(
matches!(result, PipelineOutcome::Stopped(_)),
"Expected Stopped, got {result:?}"
);
assert!(
fragment1_completed.load(Ordering::SeqCst),
"fragment 1 should have completed despite Stop"
);
assert!(
fragment2_completed.load(Ordering::SeqCst),
"fragment 2 should have completed despite Stop"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn stop_inside_split_parallel_lowest_stopped_index_wins() {
let splitter = SplitSource::Sync(Arc::new(|ex: &Exchange| {
Ok((0..3)
.map(|i| {
let mut frag = ex.clone();
frag.input.body = Body::Text(format!("from-fragment-{i}"));
frag
})
.collect())
}));
struct DualStopBody;
impl Clone for DualStopBody {
fn clone(&self) -> Self {
DualStopBody
}
}
impl camel_api::OutcomePipeline for DualStopBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(DualStopBody)
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
let is_frag0 = exchange
.input
.body
.as_text()
.map(|s| s == "from-fragment-0")
.unwrap_or(false);
let is_frag2 = exchange
.input
.body
.as_text()
.map(|s| s == "from-fragment-2")
.unwrap_or(false);
Box::pin(async move {
if is_frag0 {
return PipelineOutcome::Stopped(exchange);
}
if is_frag2 {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
return PipelineOutcome::Stopped(exchange);
}
PipelineOutcome::Completed(exchange)
})
}
}
let mut seg = SplitSegment {
splitter,
body: OutcomeSegment::new(Box::new(DualStopBody)),
parallel: true,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("test"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
match result {
PipelineOutcome::Stopped(ex) => {
assert_eq!(
ex.input.body.as_text(),
Some("from-fragment-0"),
"Lowest stopped index (0) should win, got body {:?}",
ex.input.body.as_text()
);
}
other => panic!("Expected Stopped with fragment-0 body, got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn split_parallel_limit_enforces_concurrency_cap() {
let concurrent = Arc::new(AtomicUsize::new(0));
let max_concurrent = Arc::new(AtomicUsize::new(0));
let splitter = SplitSource::Sync(Arc::new(|ex: &Exchange| {
Ok((0..6)
.map(|i| {
let mut frag = ex.clone();
frag.input.body = Body::Text(format!("frag-{i}"));
frag
})
.collect())
}));
let c = Arc::clone(&concurrent);
let mc = Arc::clone(&max_concurrent);
struct LimitedBody {
concurrent: Arc<AtomicUsize>,
max_concurrent: Arc<AtomicUsize>,
}
impl Clone for LimitedBody {
fn clone(&self) -> Self {
Self {
concurrent: Arc::clone(&self.concurrent),
max_concurrent: Arc::clone(&self.max_concurrent),
}
}
}
impl camel_api::OutcomePipeline for LimitedBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
let c = Arc::clone(&self.concurrent);
let mc = Arc::clone(&self.max_concurrent);
Box::pin(async move {
let current = c.fetch_add(1, Ordering::SeqCst) + 1;
mc.fetch_max(current, Ordering::SeqCst);
tokio::task::yield_now().await;
c.fetch_sub(1, Ordering::SeqCst);
PipelineOutcome::Completed(exchange)
})
}
}
let mut seg = SplitSegment {
splitter,
body: OutcomeSegment::new(Box::new(LimitedBody {
concurrent: c,
max_concurrent: mc,
})),
parallel: true,
parallel_limit: Some(2),
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("test"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(
matches!(result, PipelineOutcome::Completed(_)),
"Expected Completed, got {result:?}"
);
let observed_max = max_concurrent.load(Ordering::SeqCst);
assert!(
observed_max <= 2,
"parallel_limit=2 but max concurrency was {observed_max}"
);
}
#[tokio::test]
async fn split_sequential_stop_on_exception_true() {
fn make_fail_body(
fail_at: usize,
counter: Arc<AtomicUsize>,
) -> impl camel_api::OutcomePipeline + Clone {
#[derive(Clone)]
struct FailAtBody {
fail_at: usize,
counter: Arc<AtomicUsize>,
}
impl camel_api::OutcomePipeline for FailAtBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
let count = self.counter.fetch_add(1, Ordering::SeqCst);
let fail_at = self.fail_at;
Box::pin(async move {
if count == fail_at {
PipelineOutcome::Failed(CamelError::ProcessorError(format!(
"fail at {count}"
)))
} else {
PipelineOutcome::Completed(exchange)
}
})
}
}
FailAtBody { fail_at, counter }
}
let invocations = Arc::new(AtomicUsize::new(0));
let body = make_fail_body(1, Arc::clone(&invocations));
let mut seg = SplitSegment {
splitter: camel_api::SplitSource::Sync(camel_api::split_body_lines()),
body: OutcomeSegment::new(Box::new(body)),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("a\nb\nc\nd\ne"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(
matches!(result, PipelineOutcome::Failed(_)),
"stop_on_exception=true should propagate first failure"
);
assert_eq!(
invocations.load(Ordering::SeqCst),
2,
"should stop after 2 fragments (0 pass, 1 fail)"
);
}
#[tokio::test]
async fn split_sequential_stop_on_exception_false() {
fn make_fail_body(
fail_at: usize,
counter: Arc<AtomicUsize>,
) -> impl camel_api::OutcomePipeline + Clone {
#[derive(Clone)]
struct FailAtBody {
fail_at: usize,
counter: Arc<AtomicUsize>,
}
impl camel_api::OutcomePipeline for FailAtBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
let count = self.counter.fetch_add(1, Ordering::SeqCst);
let fail_at = self.fail_at;
Box::pin(async move {
if count == fail_at {
PipelineOutcome::Failed(CamelError::ProcessorError(format!(
"fail at {count}"
)))
} else {
PipelineOutcome::Completed(exchange)
}
})
}
}
FailAtBody { fail_at, counter }
}
let invocations = Arc::new(AtomicUsize::new(0));
let body = make_fail_body(1, Arc::clone(&invocations));
let mut seg = SplitSegment {
splitter: camel_api::SplitSource::Sync(camel_api::split_body_lines()),
body: OutcomeSegment::new(Box::new(body)),
parallel: false,
parallel_limit: None,
stop_on_exception: false,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("a\nb\nc\nd\ne"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(
matches!(result, PipelineOutcome::Failed(_)),
"stop_on_exception=false should still propagate error at end"
);
assert_eq!(
invocations.load(Ordering::SeqCst),
5,
"all fragments should be processed when stop_on_exception=false"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn split_parallel_stop_on_exception_true() {
let splitter = SplitSource::Sync(Arc::new(|ex: &Exchange| {
Ok((0..5)
.map(|i| {
let mut frag = ex.clone();
frag.input.body = Body::Text(format!("frag-{i}"));
frag
})
.collect())
}));
let invocations = Arc::new(AtomicUsize::new(0));
struct FailBody {
counter: Arc<AtomicUsize>,
}
impl Clone for FailBody {
fn clone(&self) -> Self {
Self {
counter: Arc::clone(&self.counter),
}
}
}
impl camel_api::OutcomePipeline for FailBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
_exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
let count = self.counter.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
PipelineOutcome::Failed(CamelError::ProcessorError(format!("fail {count}")))
})
}
}
let mut seg = SplitSegment {
splitter,
body: OutcomeSegment::new(Box::new(FailBody {
counter: Arc::clone(&invocations),
})),
parallel: true,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("test"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(
matches!(result, PipelineOutcome::Failed(_)),
"stop_on_exception=true should propagate first failure"
);
assert_eq!(
invocations.load(Ordering::SeqCst),
5,
"all fragments should be spawned"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn split_parallel_stop_on_exception_false() {
let splitter = SplitSource::Sync(Arc::new(|ex: &Exchange| {
Ok((0..5)
.map(|i| {
let mut frag = ex.clone();
frag.input.body = Body::Text(format!("frag-{i}"));
frag
})
.collect())
}));
let invocations = Arc::new(AtomicUsize::new(0));
struct MixedBody {
counter: Arc<AtomicUsize>,
}
impl Clone for MixedBody {
fn clone(&self) -> Self {
Self {
counter: Arc::clone(&self.counter),
}
}
}
impl camel_api::OutcomePipeline for MixedBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
let count = self.counter.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
if count == 1 {
PipelineOutcome::Failed(CamelError::ProcessorError("fail 1".into()))
} else {
PipelineOutcome::Completed(exchange)
}
})
}
}
let mut seg = SplitSegment {
splitter,
body: OutcomeSegment::new(Box::new(MixedBody {
counter: Arc::clone(&invocations),
})),
parallel: true,
parallel_limit: None,
stop_on_exception: false,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("test"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(
matches!(result, PipelineOutcome::Failed(_)),
"stop_on_exception=false should propagate failure at end; got {result:?}"
);
assert_eq!(
invocations.load(Ordering::SeqCst),
5,
"all fragments should be spawned"
);
}
#[tokio::test]
async fn test_split_segment_expression_error_is_failed() {
#[derive(Clone)]
struct RecordingBody {
counter: Arc<AtomicUsize>,
}
impl camel_api::OutcomePipeline for RecordingBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
self.counter.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { PipelineOutcome::Completed(exchange) })
}
}
let invocations = Arc::new(AtomicUsize::new(0));
let splitter = SplitSource::Sync(Arc::new(|_| {
Err(CamelError::TypeConversionFailed(
"declarative split requires a text or array value, got number; add an unmarshal step before split"
.to_string(),
))
}));
let mut seg = SplitSegment {
splitter,
body: OutcomeSegment::new(Box::new(RecordingBody {
counter: Arc::clone(&invocations),
})),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("anything"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
match result {
PipelineOutcome::Failed(err) => {
let msg = err.to_string();
assert!(
msg.contains("declarative split"),
"carried error should mention 'declarative split': {msg}"
);
}
other => panic!("Expected Failed, got {other:?}"),
}
assert_eq!(
invocations.load(Ordering::SeqCst),
0,
"body segment must record zero invocations when the split expression errors"
);
}
#[tokio::test]
async fn split_segment_wrong_type_body_fails_loud() {
let invocations = Arc::new(AtomicUsize::new(0));
#[derive(Clone)]
struct RecordingBody {
counter: Arc<AtomicUsize>,
}
impl camel_api::OutcomePipeline for RecordingBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
self.counter.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { PipelineOutcome::Completed(exchange) })
}
}
let mut seg = SplitSegment {
splitter: SplitSource::Sync(camel_api::split_body_json_array()),
body: OutcomeSegment::new(Box::new(RecordingBody {
counter: Arc::clone(&invocations),
})),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new(Body::Text("a,b".to_string())));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
match result {
PipelineOutcome::Failed(err) => {
let msg = err.to_string();
for needle in [
"body_json_array",
"text",
"json (array)",
"add an unmarshal step before split",
] {
assert!(msg.contains(needle), "message '{msg}' missing '{needle}'");
}
}
other => panic!("wrong body type must fail loud, got {other:?}"),
}
assert_eq!(
invocations.load(Ordering::SeqCst),
0,
"body segment must record zero invocations when the split expression errors"
);
}
#[tokio::test]
async fn split_segment_async_error_propagates_before_fragments() {
use camel_api::BoxValueFuture;
let invocations = Arc::new(AtomicUsize::new(0));
#[derive(Clone)]
struct RecordingBody {
counter: Arc<AtomicUsize>,
}
impl camel_api::OutcomePipeline for RecordingBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
self.counter.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { PipelineOutcome::Completed(exchange) })
}
}
let splitter = SplitSource::Async(Arc::new(|_: &Exchange| {
Box::pin(async { Err(CamelError::ProcessorError("split boom".into())) })
as BoxValueFuture
}));
let mut seg = SplitSegment {
splitter,
body: OutcomeSegment::new(Box::new(RecordingBody {
counter: Arc::clone(&invocations),
})),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("a\nb\nc"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(
matches!(result, PipelineOutcome::Failed(CamelError::ProcessorError(ref e)) if e.contains("split boom")),
"an async split-expression failure must propagate; got {result:?}"
);
assert_eq!(
invocations.load(Ordering::SeqCst),
0,
"no fragment may reach the sub-pipeline when the split expression fails"
);
}
#[derive(Clone)]
struct MetadataCaptureBody {
triples: Arc<std::sync::Mutex<Vec<(u64, u64, bool)>>>,
}
impl camel_api::OutcomePipeline for MetadataCaptureBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
let triples = Arc::clone(&self.triples);
Box::pin(async move {
let index = exchange
.property(crate::splitter::CAMEL_SPLIT_INDEX)
.and_then(|v| v.as_u64())
.unwrap_or(u64::MAX);
let size = exchange
.property(crate::splitter::CAMEL_SPLIT_SIZE)
.and_then(|v| v.as_u64())
.unwrap_or(u64::MAX);
let complete = exchange
.property(crate::splitter::CAMEL_SPLIT_COMPLETE)
.and_then(|v| v.as_bool())
.unwrap_or(false);
triples.lock().unwrap().push((index, size, complete));
PipelineOutcome::Completed(exchange)
})
}
}
fn n_fragment_splitter(n: u64) -> SplitSource {
SplitSource::Sync(Arc::new(move |ex: &Exchange| {
Ok((0..n)
.map(|i| {
let mut frag = ex.clone();
frag.input.body = Body::Text(format!("frag-{i}"));
frag
})
.collect())
}))
}
#[tokio::test]
async fn sequential_split_stamps_fragment_metadata() {
let triples = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut seg = SplitSegment {
splitter: n_fragment_splitter(3),
body: OutcomeSegment::new(Box::new(MetadataCaptureBody {
triples: Arc::clone(&triples),
})),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("test"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(
matches!(result, PipelineOutcome::Completed(_)),
"Expected Completed, got {result:?}"
);
assert_eq!(
*triples.lock().unwrap(),
vec![(0, 3, false), (1, 3, false), (2, 3, true)]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn parallel_split_stamps_fragment_metadata() {
let triples = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut seg = SplitSegment {
splitter: n_fragment_splitter(3),
body: OutcomeSegment::new(Box::new(MetadataCaptureBody {
triples: Arc::clone(&triples),
})),
parallel: true,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("test"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(
matches!(result, PipelineOutcome::Completed(_)),
"Expected Completed, got {result:?}"
);
let mut got = triples.lock().unwrap().clone();
got.sort_by_key(|(idx, _, _)| *idx);
assert_eq!(got, vec![(0, 3, false), (1, 3, false), (2, 3, true)]);
}
#[tokio::test]
async fn single_fragment_is_complete() {
let triples = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut seg = SplitSegment {
splitter: n_fragment_splitter(1),
body: OutcomeSegment::new(Box::new(MetadataCaptureBody {
triples: Arc::clone(&triples),
})),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let ex = Exchange::new(Message::new("test"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
assert!(
matches!(result, PipelineOutcome::Completed(_)),
"Expected Completed, got {result:?}"
);
assert_eq!(*triples.lock().unwrap(), vec![(0, 1, true)]);
}
#[tokio::test]
async fn split_segment_empty_fragments_returns_original_unchanged() {
let mut seg = SplitSegment {
splitter: SplitSource::Sync(camel_api::split_body_lines()),
body: OutcomeSegment::new(Box::new(CompletedBody)),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let mut ex = Exchange::new(Message::default()); ex.set_property("marker", Value::Bool(true));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
match result {
PipelineOutcome::Completed(out) => {
assert!(out.input.body.is_empty());
assert_eq!(out.property("marker"), Some(&Value::Bool(true)));
}
other => panic!("Expected Completed(original), got {other:?}"),
}
}
#[derive(Clone)]
struct MetadataJsonBody;
impl camel_api::OutcomePipeline for MetadataJsonBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(MetadataJsonBody)
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
Box::pin(async move {
let index = exchange
.property(crate::splitter::CAMEL_SPLIT_INDEX)
.cloned();
let size = exchange
.property(crate::splitter::CAMEL_SPLIT_SIZE)
.cloned();
let complete = exchange
.property(crate::splitter::CAMEL_SPLIT_COMPLETE)
.cloned();
let mut out = exchange;
out.input.body = Body::Json(serde_json::json!({
"index": index,
"size": size,
"complete": complete,
}));
PipelineOutcome::Completed(out)
})
}
}
#[tokio::test]
async fn split_segment_metadata_properties() {
let mut seg = SplitSegment {
splitter: SplitSource::Sync(camel_api::split_body_lines()),
body: OutcomeSegment::new(Box::new(MetadataJsonBody)),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::CollectAll,
};
let ex = Exchange::new(Message::new("x\ny\nz"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
let expected = serde_json::json!([
{"index": 0, "size": 3, "complete": false},
{"index": 1, "size": 3, "complete": false},
{"index": 2, "size": 3, "complete": true},
]);
match result {
PipelineOutcome::Completed(out) => match &out.input.body {
Body::Json(v) => assert_eq!(*v, expected),
other => panic!("expected JSON body, got {other:?}"),
},
other => panic!("Expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn split_segment_json_array_body() {
let mut seg = SplitSegment {
splitter: SplitSource::Sync(camel_api::split_body_json_array()),
body: OutcomeSegment::new(Box::new(CompletedBody)),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::CollectAll,
};
let msg = Message::new(Body::Json(serde_json::json!([1, 2, 3])));
let parent = Exchange::new(msg);
let result = camel_api::OutcomePipeline::run(&mut seg, parent).await;
match result {
PipelineOutcome::Completed(out) => match &out.input.body {
Body::Json(serde_json::Value::Array(arr)) => {
assert_eq!(
arr,
&vec![
serde_json::json!(1),
serde_json::json!(2),
serde_json::json!(3)
]
);
}
other => panic!("expected JSON array body, got {other:?}"),
},
other => panic!("Expected Completed, got {other:?}"),
}
}
#[derive(Clone)]
struct CorrelationIdBody;
impl camel_api::OutcomePipeline for CorrelationIdBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(CorrelationIdBody)
}
fn run<'a>(
&'a mut self,
mut exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
Box::pin(async move {
exchange.input.body = Body::Text(exchange.correlation_id().to_string());
PipelineOutcome::Completed(exchange)
})
}
}
#[tokio::test]
async fn split_segment_each_fragment_has_unique_correlation_id() {
let mut seg = SplitSegment {
splitter: SplitSource::Sync(camel_api::split_body_lines()),
body: OutcomeSegment::new(Box::new(CorrelationIdBody)),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::CollectAll,
};
let ex = Exchange::new(Message::new("a\nb\nc\nd"));
let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
let ids: Vec<String> = match result {
PipelineOutcome::Completed(out) => match &out.input.body {
Body::Json(serde_json::Value::Array(arr)) => arr
.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.collect(),
other => panic!("expected JSON array of ids, got {other:?}"),
},
other => panic!("Expected Completed, got {other:?}"),
};
assert_eq!(ids.len(), 4, "should have 4 fragments");
let unique: std::collections::HashSet<&String> = ids.iter().collect();
assert_eq!(unique.len(), 4, "fragment correlation_ids must be unique");
}
#[derive(Clone)]
struct CountingBody {
counter: Arc<AtomicUsize>,
}
impl camel_api::OutcomePipeline for CountingBody {
fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
Box::new(self.clone())
}
fn run<'a>(
&'a mut self,
exchange: Exchange,
) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
self.counter.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { PipelineOutcome::Completed(exchange) })
}
}
fn counting_body(counter: &Arc<AtomicUsize>) -> OutcomeSegment {
OutcomeSegment::new(Box::new(CountingBody {
counter: Arc::clone(counter),
}))
}
#[tokio::test]
async fn split_rejects_above_custom_cap() {
let invocations = Arc::new(AtomicUsize::new(0));
let mut seg = SplitSegment {
splitter: n_fragment_splitter(5),
body: counting_body(&invocations),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: 4,
aggregation: AggregationStrategy::LastWins,
};
let result =
camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
match result {
PipelineOutcome::Failed(err) => assert!(
err.to_string()
.contains("Splitter produced 5 fragments, exceeding max_fragments 4"),
"error should name the produced count and cap, got {err}"
),
other => panic!("expected Failed, got {other:?}"),
}
assert_eq!(
invocations.load(Ordering::SeqCst),
0,
"no fragment may run when the cap is exceeded"
);
}
#[tokio::test]
async fn split_accepts_at_and_below_custom_cap() {
let below = Arc::new(AtomicUsize::new(0));
let mut below_seg = SplitSegment {
splitter: n_fragment_splitter(3),
body: counting_body(&below),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: 4,
aggregation: AggregationStrategy::LastWins,
};
let below_result =
camel_api::OutcomePipeline::run(&mut below_seg, Exchange::new(Message::new("test")))
.await;
assert!(
matches!(below_result, PipelineOutcome::Completed(_)),
"cap-1 must complete, got {below_result:?}"
);
assert_eq!(below.load(Ordering::SeqCst), 3, "cap-1 should run 3 bodies");
let at = Arc::new(AtomicUsize::new(0));
let mut at_seg = SplitSegment {
splitter: n_fragment_splitter(4),
body: counting_body(&at),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: 4,
aggregation: AggregationStrategy::LastWins,
};
let at_result =
camel_api::OutcomePipeline::run(&mut at_seg, Exchange::new(Message::new("test"))).await;
assert!(
matches!(at_result, PipelineOutcome::Completed(_)),
"exactly-at-cap must complete, got {at_result:?}"
);
assert_eq!(at.load(Ordering::SeqCst), 4, "cap should run 4 bodies");
}
#[tokio::test(flavor = "multi_thread")]
async fn split_rejects_before_spawning_parallel() {
let invocations = Arc::new(AtomicUsize::new(0));
let mut seg = SplitSegment {
splitter: n_fragment_splitter(3),
body: counting_body(&invocations),
parallel: true,
parallel_limit: None,
stop_on_exception: true,
max_fragments: 2,
aggregation: AggregationStrategy::LastWins,
};
let result =
camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
match result {
PipelineOutcome::Failed(err) => assert!(
err.to_string()
.contains("Splitter produced 3 fragments, exceeding max_fragments 2"),
"error should name the produced count and cap, got {err}"
),
other => panic!("expected Failed, got {other:?}"),
}
assert_eq!(
invocations.load(Ordering::SeqCst),
0,
"no parallel task may spawn when the cap is exceeded"
);
}
#[tokio::test]
async fn split_rejects_before_processing_sequential() {
let invocations = Arc::new(AtomicUsize::new(0));
let mut seg = SplitSegment {
splitter: n_fragment_splitter(3),
body: counting_body(&invocations),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: 2,
aggregation: AggregationStrategy::LastWins,
};
let result =
camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
match result {
PipelineOutcome::Failed(err) => assert!(
err.to_string()
.contains("Splitter produced 3 fragments, exceeding max_fragments 2"),
"error should name the produced count and cap, got {err}"
),
other => panic!("expected Failed, got {other:?}"),
}
assert_eq!(
invocations.load(Ordering::SeqCst),
0,
"no fragment may run when the cap is exceeded"
);
}
#[tokio::test]
async fn split_empty_fragments_pass_through_with_cap() {
let invocations = Arc::new(AtomicUsize::new(0));
let splitter = SplitSource::Sync(
Arc::new(|_: &Exchange| Ok(Vec::new())) as camel_api::SplitExpression
);
let mut seg = SplitSegment {
splitter,
body: counting_body(&invocations),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: 4,
aggregation: AggregationStrategy::LastWins,
};
let result =
camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("original")))
.await;
match result {
PipelineOutcome::Completed(out) => {
assert_eq!(
out.input.body.as_text(),
Some("original"),
"empty split must pass the original exchange through"
);
}
other => panic!("expected Completed, got {other:?}"),
}
assert_eq!(
invocations.load(Ordering::SeqCst),
0,
"an empty split must not invoke the body"
);
}
#[tokio::test]
async fn split_default_cap_respected() {
assert_eq!(camel_api::splitter::DEFAULT_MAX_FRAGMENTS, 100_000);
let invocations = Arc::new(AtomicUsize::new(0));
let mut seg = SplitSegment {
splitter: n_fragment_splitter(3),
body: counting_body(&invocations),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let result =
camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
assert!(
matches!(result, PipelineOutcome::Completed(_)),
"3 fragments are well below the default cap, got {result:?}"
);
assert_eq!(invocations.load(Ordering::SeqCst), 3, "all 3 bodies run");
}
#[tokio::test]
async fn split_default_cap_plus_one_rejected() {
let invocations = Arc::new(AtomicUsize::new(0));
let mut seg = SplitSegment {
splitter: n_fragment_splitter(100_001),
body: counting_body(&invocations),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
aggregation: AggregationStrategy::LastWins,
};
let result =
camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
match result {
PipelineOutcome::Failed(err) => assert!(
err.to_string().contains("exceeding max_fragments 100000"),
"default cap error should name 100000, got {err}"
),
other => panic!("expected Failed, got {other:?}"),
}
assert_eq!(
invocations.load(Ordering::SeqCst),
0,
"no fragment may run when the default cap is exceeded"
);
}
#[tokio::test]
async fn split_zero_cap_fails_closed_non_empty() {
let invocations = Arc::new(AtomicUsize::new(0));
let mut seg = SplitSegment {
splitter: n_fragment_splitter(2),
body: counting_body(&invocations),
parallel: false,
parallel_limit: None,
stop_on_exception: true,
max_fragments: 0,
aggregation: AggregationStrategy::LastWins,
};
let result =
camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
match result {
PipelineOutcome::Failed(err) => assert!(
err.to_string().contains("exceeding max_fragments 0"),
"zero cap must fail closed for non-empty splits, got {err}"
),
other => panic!("expected Failed, got {other:?}"),
}
assert_eq!(
invocations.load(Ordering::SeqCst),
0,
"zero cap must reject before processing"
);
}
}