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