Skip to main content

camel_processor/
multicast_segment.rs

1//! ## Stop semantics (ADR-0025)
2//!
3//! This segment implements `OutcomePipeline` and propagates `PipelineOutcome::Stopped(ex)`
4//! with the exchange state intact. See ADR-0025 §3.
5
6use futures::FutureExt;
7use std::future::Future;
8use std::panic::AssertUnwindSafe;
9use std::pin::Pin;
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
12use std::time::Duration;
13use tokio::task::JoinSet;
14
15use camel_api::{Exchange, Value};
16
17// ── Metadata property keys ─────────────────────────────────────────────
18
19/// Property key for the zero-based index of the endpoint being invoked.
20pub const CAMEL_MULTICAST_INDEX: &str = "CamelMulticastIndex";
21/// Property key indicating whether this is the last endpoint invocation.
22pub const CAMEL_MULTICAST_COMPLETE: &str = "CamelMulticastComplete";
23
24// ── MulticastSegment (ADR-0025 OutcomePipeline) ──────────────────────────
25
26/// Outcome-aware Multicast segment. Holds N child OutcomeSegments and a
27/// strategy (sequential or parallel). Parallel cancellation logic mirrors
28/// T13 SplitSegment — lower-the-value CAS records lowest-branch-index that
29/// Stopped (spec §5.2.2 line 497); pre-start gate skips not-yet-started
30/// branches; in-flight branches run to completion (spec §5.6 line 544:
31/// no abrupt abort); JoinSet ensures cancel-safe drop on outer future drop.
32///
33/// Each branch receives its OWN clone of the exchange (Multicast semantics —
34/// branches do NOT share body mutations).
35///
36/// Aggregation SKIPPED when any branch Stopped (spec §5.2.2).
37#[derive(Clone)]
38pub struct MulticastSegment {
39    pub branches: Vec<camel_api::OutcomeSegment>,
40    pub parallel: bool,
41    /// Maximum number of concurrent branches in parallel mode (None = unlimited).
42    pub parallel_limit: Option<usize>,
43    /// Whether to stop processing on the first exception.
44    ///
45    /// When `true`, a `Failed` outcome from any branch halts processing
46    /// immediately (sequential) or propagates the first `Failed` branch
47    /// (lowest branch index, parallel). When `false`, failures are collected and
48    /// processing continues: a zero-success run propagates the representative
49    /// error (last-wins), while a partial-success run aggregates the successful
50    /// branches' outputs only and discards the failed outcomes (logged at warn).
51    ///
52    /// `Stopped` outcomes always propagate per ADR-0025 §7 regardless of this flag.
53    pub stop_on_exception: bool,
54    /// Per-branch timeout in parallel mode (None = no timeout).
55    pub timeout: Option<Duration>,
56    pub aggregator: Arc<dyn Fn(Vec<Exchange>) -> Exchange + Send + Sync>,
57}
58
59impl camel_api::OutcomePipeline for MulticastSegment {
60    fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
61        Box::new(self.clone())
62    }
63
64    fn run<'a>(
65        &'a mut self,
66        exchange: Exchange,
67    ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
68        Box::pin(async move {
69            if self.parallel {
70                parallel_multicast(self, exchange).await
71            } else {
72                sequential_multicast(self, exchange).await
73            }
74        })
75    }
76}
77
78// ── Sequential multicast ─────────────────────────────────────────────────
79
80async fn sequential_multicast(
81    seg: &mut MulticastSegment,
82    exchange: Exchange,
83) -> camel_api::PipelineOutcome {
84    let mut outputs = Vec::new();
85    let mut last_error: Option<camel_api::CamelError> = None;
86    let total = seg.branches.len();
87    for (i, branch) in seg.branches.iter_mut().enumerate() {
88        // Each branch gets a clone (Multicast semantics — no shared mutations).
89        let mut ex = exchange.branch_clone();
90        ex.set_property(CAMEL_MULTICAST_INDEX, Value::from(i as i64));
91        ex.set_property(CAMEL_MULTICAST_COMPLETE, Value::Bool(i == total - 1));
92        match branch.run(ex).await {
93            camel_api::PipelineOutcome::Completed(ex) => outputs.push(ex),
94            camel_api::PipelineOutcome::Stopped(ex) => {
95                return camel_api::PipelineOutcome::Stopped(ex);
96            }
97            camel_api::PipelineOutcome::Failed(err) => {
98                if seg.stop_on_exception {
99                    return camel_api::PipelineOutcome::Failed(err);
100                }
101                // stop_on_exception=false: collect error, continue.
102                last_error = Some(err);
103            }
104        }
105    }
106    if let Some(err) = last_error {
107        if outputs.is_empty() {
108            return camel_api::PipelineOutcome::Failed(err);
109        }
110        // log-policy: handler-owned
111        tracing::warn!(
112            failed_branches = total - outputs.len(),
113            branch_count = total,
114            "multicast partial success: discarding failed branch outcomes"
115        );
116    }
117    camel_api::PipelineOutcome::Completed((seg.aggregator)(outputs))
118}
119
120// ── Parallel multicast ──────────────────────────────────────────────────
121
122/// Parallel multicast with lowest-index-wins CAS semantics.
123///
124/// See spec §5.2.2 line 497 for the CAS guarantee and §5.6 line 544 for the
125/// "no abrupt abort" in-flight task policy (pre-start gate + run-to-completion).
126async fn parallel_multicast(
127    seg: &mut MulticastSegment,
128    exchange: Exchange,
129) -> camel_api::PipelineOutcome {
130    use std::sync::Arc;
131    use tokio::sync::Semaphore;
132
133    let stopped_seen = Arc::new(AtomicBool::new(false));
134    let stopped_idx = Arc::new(AtomicUsize::new(usize::MAX));
135    let semaphore = seg
136        .parallel_limit
137        .filter(|&limit| limit > 0)
138        .map(|limit| Arc::new(Semaphore::new(limit)));
139    let timeout = seg.timeout;
140    let stop_on_exception = seg.stop_on_exception;
141    let total = seg.branches.len();
142
143    let mut set: JoinSet<(usize, Option<camel_api::PipelineOutcome>)> = JoinSet::new();
144
145    for (idx, mut branch) in seg.branches.clone().into_iter().enumerate() {
146        let stopped_seen = Arc::clone(&stopped_seen);
147        let stopped_idx = Arc::clone(&stopped_idx);
148        let sem = semaphore.clone();
149        // Each branch gets its OWN clone of the exchange (Multicast semantics).
150        let mut ex = exchange.branch_clone();
151        ex.set_property(CAMEL_MULTICAST_INDEX, Value::from(idx as i64));
152        ex.set_property(CAMEL_MULTICAST_COMPLETE, Value::Bool(idx == total - 1));
153        set.spawn(async move {
154            // Pre-start gate: a lower-index branch already stopped.
155            if stopped_seen.load(Ordering::SeqCst) {
156                return (idx, None);
157            }
158            // Acquire semaphore permit if parallel_limit is set.
159            let _permit: Option<tokio::sync::OwnedSemaphorePermit> = match &sem {
160                Some(s) => match Arc::clone(s).acquire_owned().await {
161                    Ok(p) => Some(p),
162                    Err(_) => {
163                        return (
164                            idx,
165                            Some(camel_api::PipelineOutcome::Failed(
166                                camel_api::CamelError::ProcessorError("semaphore closed".into()),
167                            )),
168                        );
169                    }
170                },
171                None => None,
172            };
173            // Re-check pre-start gate after permit acquisition.
174            if stopped_seen.load(Ordering::SeqCst) {
175                return (idx, None);
176            }
177
178            // Run body (Stop CAS stays inside the caught future so a Stopped
179            // branch is still observed before any unwind mapping).
180            let outcome = async {
181                let outcome = branch.run(ex).await;
182                if let camel_api::PipelineOutcome::Stopped(_) = &outcome {
183                    // Lower-the-value CAS.
184                    loop {
185                        let cur = stopped_idx.load(Ordering::SeqCst);
186                        if idx >= cur {
187                            break;
188                        }
189                        match stopped_idx.compare_exchange_weak(
190                            cur,
191                            idx,
192                            Ordering::SeqCst,
193                            Ordering::SeqCst,
194                        ) {
195                            Ok(_) => break,
196                            Err(actual) => {
197                                if actual <= idx {
198                                    break;
199                                }
200                            }
201                        }
202                    }
203                    stopped_seen.store(true, Ordering::SeqCst);
204                }
205                outcome
206            };
207
208            // Catch unwinds inside the task so the branch index survives (a
209            // dropped JoinError loses it). Segment-outcome-composition spec's
210            // panicked-branch classification clause (bd rc-f88o): a panicking
211            // parallel branch is zero-success attempted work under ADR-0058 and
212            // maps to a representative Failed(ProcessorError) instead of being
213            // silently dropped from outcome/accounting/last_error. mem::forget
214            // avoids a second panic (payload Drop) outside catch_unwind, which
215            // would recreate the dropped-JoinError defect; see recipient_list.rs
216            // panic arm for the same contract.
217            let outcome = AssertUnwindSafe(outcome).catch_unwind();
218            let outcome = if let Some(dur) = timeout {
219                match tokio::time::timeout(dur, outcome).await {
220                    Ok(Ok(o)) => o,
221                    Ok(Err(panic_payload)) => {
222                        let failure = camel_api::PipelineOutcome::Failed(
223                            camel_api::CamelError::ProcessorError(format!(
224                                "multicast branch {idx} panicked"
225                            )),
226                        );
227                        std::mem::forget(panic_payload);
228                        failure
229                    }
230                    Err(_elapsed) => {
231                        camel_api::PipelineOutcome::Failed(camel_api::CamelError::ProcessorError(
232                            format!("multicast branch {idx} timed out after {dur:?}"),
233                        ))
234                    }
235                }
236            } else {
237                match outcome.await {
238                    Ok(outcome) => outcome,
239                    Err(panic_payload) => {
240                        let failure = camel_api::PipelineOutcome::Failed(
241                            camel_api::CamelError::ProcessorError(format!(
242                                "multicast branch {idx} panicked"
243                            )),
244                        );
245                        std::mem::forget(panic_payload);
246                        failure
247                    }
248                }
249            };
250
251            (idx, Some(outcome))
252        });
253    }
254
255    // Wait for ALL in-flight branches to finish.
256    let mut results: Vec<(usize, camel_api::PipelineOutcome)> = Vec::new();
257    while let Some(res) = set.join_next().await {
258        if let Ok((idx, Some(o))) = res {
259            results.push((idx, o));
260        }
261    }
262
263    // Deterministic lowest-branch-index wins for Stop.
264    if stopped_seen.load(Ordering::SeqCst) {
265        let winning_idx = stopped_idx.load(Ordering::SeqCst);
266        if winning_idx == usize::MAX {
267            tracing::warn!(
268                target: "camel.phase4.multicast",
269                "stopped_seen=true but stopped_idx=usize::MAX — race; falling back to pre-multicast exchange"
270            );
271            return camel_api::PipelineOutcome::Stopped(exchange);
272        }
273        let stopped_ex = results
274            .iter()
275            .find(|(idx, _)| *idx == winning_idx)
276            .and_then(|(_, o)| match o {
277                camel_api::PipelineOutcome::Stopped(ex) => Some(ex.branch_clone()),
278                _ => None,
279            });
280        if let Some(ex) = stopped_ex {
281            return camel_api::PipelineOutcome::Stopped(ex);
282        }
283        tracing::warn!(
284            target: "camel.phase4.multicast",
285            winning_idx = winning_idx,
286            "winning_idx not found — falling back to pre-multicast exchange"
287        );
288        return camel_api::PipelineOutcome::Stopped(exchange);
289    }
290
291    // Check for Failed outcomes.
292    // stop_on_exception=true: propagate first Failed (lowest branch index).
293    // stop_on_exception=false: collect last error (last-wins) and defer to the
294    // shared-tail guard after aggregation.
295    results.sort_by_key(|(idx, _)| *idx);
296    let mut last_error: Option<camel_api::CamelError> = None;
297    if stop_on_exception {
298        let mut first_failed: Option<(usize, camel_api::CamelError)> = None;
299        for (idx, o) in &results {
300            if let camel_api::PipelineOutcome::Failed(err) = o
301                && first_failed
302                    .as_ref()
303                    .map(|(i, _)| *i > *idx)
304                    .unwrap_or(true)
305            {
306                first_failed = Some((*idx, err.clone()));
307            }
308        }
309        if let Some((_, err)) = first_failed {
310            return camel_api::PipelineOutcome::Failed(err);
311        }
312    } else {
313        // Collect last error (last-wins, matching legacy LastWins semantics).
314        for (_, o) in &results {
315            if let camel_api::PipelineOutcome::Failed(err) = o {
316                last_error = Some(err.clone());
317            }
318        }
319    }
320
321    // Count actual Failed slots (not pre-start-gate-skipped None slots) before
322    // `results` is consumed below.
323    let failed_branches = results
324        .iter()
325        .filter(|(_, o)| matches!(o, camel_api::PipelineOutcome::Failed(_)))
326        .count();
327
328    // Single aggregation point — Completed outcomes only.
329    let completed: Vec<Exchange> = results
330        .into_iter()
331        .filter_map(|(_, o)| match o {
332            camel_api::PipelineOutcome::Completed(ex) => Some(ex),
333            _ => None,
334        })
335        .collect();
336
337    if let Some(err) = last_error {
338        if completed.is_empty() {
339            return camel_api::PipelineOutcome::Failed(err);
340        }
341        // log-policy: handler-owned
342        tracing::warn!(
343            failed_branches,
344            branch_count = total,
345            "multicast partial success: discarding failed branch outcomes"
346        );
347    }
348    camel_api::PipelineOutcome::Completed((seg.aggregator)(completed))
349}
350
351#[cfg(test)]
352#[path = "multicast_segment_tests.rs"]
353mod tests;