Skip to main content

flowcat_core/pipeline/
parallel.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! `ParallelPipeline` — fan-out / fan-in with lifecycle sync
4//! (PROCESSOR-DESIGN §3.2).
5//!
6//! A frame entering the parallel block is queued into **every** branch's head.
7//! Each branch's tail output funnels into a single downstream, **de-duplicated by
8//! `meta.id`** (a frame fanned to N branches is emitted once). Lifecycle frames
9//! (`Start`/`End`/`Cancel`) are **synchronized**: the block buffers non-lifecycle
10//! output while a lifecycle frame is in flight and only forwards the lifecycle
11//! frame (and flushes the buffer) once *all* branches have passed it — so a fast
12//! branch's `End` can't shut the transport down while a slow branch still has
13//! audio to flush. Mirrors pipecat `ParallelPipeline` (`parallel_pipeline.py:24`).
14
15use std::collections::HashSet;
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use tokio::task::JoinSet;
20
21use crate::processor::frame::Frame;
22use crate::processor::runtime::{channel, ProcessorRx, ProcessorTx, NORMAL_CHAN_CAP};
23use crate::processor::{into_topology, Envelope, FrameProcessor, ProcessorSetup, Topology};
24
25use super::{link_topology, Linked, Pipeline};
26
27/// A fan-out / fan-in block of parallel [`Pipeline`] branches
28/// (PROCESSOR-DESIGN §3.2). Itself a [`FrameProcessor`] (so it nests).
29pub struct ParallelPipeline {
30    branches: Vec<Pipeline>,
31}
32
33impl ParallelPipeline {
34    /// Build a parallel block over `branches`.
35    pub fn new(branches: Vec<Pipeline>) -> Self {
36        Self { branches }
37    }
38
39    /// Number of branches.
40    pub fn len(&self) -> usize {
41        self.branches.len()
42    }
43
44    pub fn is_empty(&self) -> bool {
45        self.branches.is_empty()
46    }
47}
48
49#[async_trait]
50impl FrameProcessor for ParallelPipeline {
51    fn name(&self) -> &str {
52        "ParallelPipeline"
53    }
54
55    fn explode(&mut self) -> Option<Topology> {
56        let branches = std::mem::take(&mut self.branches);
57        Some(Topology::Parallel(
58            branches
59                .into_iter()
60                .map(|b| into_topology(Box::new(b)))
61                .collect(),
62        ))
63    }
64}
65
66/// Spawn a parallel block: fan `prev→branches→next` with id-dedup + lifecycle
67/// sync. Returns the block's external head/tail input senders.
68pub(crate) fn link_parallel(
69    branches: Vec<Topology>,
70    prev: Option<ProcessorTx>,
71    next: Option<ProcessorTx>,
72    setup: &ProcessorSetup,
73    tasks: &mut JoinSet<()>,
74) -> Linked {
75    let branch_count = branches.len().max(1);
76
77    // The block's downstream head: a forwarding channel the fan-out task reads.
78    let (head_tx, head_rx) = channel(Arc::from("ParallelIn"), NORMAL_CHAN_CAP);
79    // The block's upstream tail: branches push upstream here; we forward to `prev`.
80    let (tail_tx, tail_rx) = channel(Arc::from("ParallelOut"), NORMAL_CHAN_CAP);
81
82    // Fan-in collector: every branch tail pushes its DOWNSTREAM output here.
83    let (fanin_tx, fanin_rx) = channel(Arc::from("ParallelFanIn"), NORMAL_CHAN_CAP);
84    // Upstream collector: every branch head pushes UPSTREAM here.
85    let (upstream_tx, upstream_rx) = channel(Arc::from("ParallelUpstream"), NORMAL_CHAN_CAP);
86
87    // Link each branch with prev = upstream_collector, next = fanin_collector.
88    let mut branch_heads: Vec<ProcessorTx> = Vec::with_capacity(branch_count);
89    for branch in branches {
90        let linked = link_topology(
91            branch,
92            Some(upstream_tx.clone()),
93            Some(fanin_tx.clone()),
94            setup,
95            tasks,
96        );
97        branch_heads.push(linked.head);
98    }
99    drop(fanin_tx);
100    drop(upstream_tx);
101
102    // Fan-out task: read head_rx, push each frame into EVERY branch head.
103    tasks.spawn(fan_out(head_rx, branch_heads));
104
105    // Fan-in task: dedup by id + lifecycle sync, then push to `next`.
106    tasks.spawn(fan_in(fanin_rx, next, branch_count));
107
108    // Upstream forwarder: branches → `prev` (no dedup needed; pass through).
109    tasks.spawn(forward(upstream_rx, prev));
110
111    // Tail forwarder: upstream injection at a parallel block's tail is not wired
112    // (a `PipelineTask` only injects at the head — see `Linked::tail`'s
113    // `#[allow(dead_code)]` in `pipeline/mod.rs`). We drain `tail_rx` to `None` so a
114    // stray tail injection is dropped deliberately, not silently queued forever. If
115    // tail-upstream injection is needed in the future, route this into the upstream
116    // collector path instead of `None`.
117    tasks.spawn(forward(tail_rx, None));
118
119    Linked {
120        head: head_tx,
121        tail: tail_tx,
122    }
123}
124
125/// Push every frame from `rx` into every branch head (the fan-out).
126async fn fan_out(mut rx: ProcessorRx, branch_heads: Vec<ProcessorTx>) {
127    loop {
128        let env = tokio::select! {
129            biased;
130            Some(e) = rx.system.recv() => e,
131            Some(e) = rx.normal.recv() => e,
132            else => break,
133        };
134        let terminal = env.frame.is_terminal();
135        for head in &branch_heads {
136            head.send(env.clone()).await;
137        }
138        if terminal {
139            break;
140        }
141    }
142}
143
144/// Collect downstream output from all branches; dedup by `meta.id`; synchronize
145/// lifecycle frames (forward once all branches passed them, buffering other
146/// output meanwhile); forward to `next`.
147async fn fan_in(mut rx: ProcessorRx, next: Option<ProcessorTx>, branch_count: usize) {
148    let mut seen_ids: HashSet<u64> = HashSet::new();
149    // For the *currently-syncing* lifecycle frame: how many branches have passed.
150    let mut lifecycle_pending: Option<(Frame, usize)> = None;
151    let mut buffer: Vec<Envelope> = Vec::new();
152
153    loop {
154        let env = tokio::select! {
155            biased;
156            Some(e) = rx.system.recv() => e,
157            Some(e) = rx.normal.recv() => e,
158            else => break,
159        };
160
161        if is_synced_lifecycle(&env.frame) {
162            // A lifecycle frame from one branch. Count distinct sibling arrivals
163            // by id-dedup: each branch emits its own copy (distinct id). Track by
164            // a per-lifecycle counter keyed on the frame kind.
165            match &mut lifecycle_pending {
166                Some((pending, count)) if same_lifecycle(pending, &env.frame) => {
167                    *count += 1;
168                    if *count >= branch_count {
169                        // All branches passed it: forward the lifecycle frame, then
170                        // flush the buffer, then reset.
171                        forward_one(&next, env).await;
172                        for buffered in buffer.drain(..) {
173                            forward_one(&next, buffered).await;
174                        }
175                        lifecycle_pending = None;
176                    }
177                }
178                Some(_) => {
179                    // A different lifecycle while one is pending — buffer it.
180                    buffer.push(env);
181                }
182                None => {
183                    lifecycle_pending = Some((env.frame.clone(), 1));
184                    if branch_count <= 1 {
185                        // Single branch: forward immediately.
186                        forward_one(&next, env).await;
187                        lifecycle_pending = None;
188                    }
189                }
190            }
191            continue;
192        }
193
194        // Non-lifecycle frame: dedup by id.
195        if !seen_ids.insert(env.meta.id) {
196            continue; // already emitted this fanned frame
197        }
198        if lifecycle_pending.is_some() {
199            // Buffer non-lifecycle output until the lifecycle sync completes.
200            buffer.push(env);
201        } else {
202            forward_one(&next, env).await;
203        }
204    }
205}
206
207/// Plain forwarder: drain `rx` into `dst` (or drop if `dst` is `None`).
208async fn forward(mut rx: ProcessorRx, dst: Option<ProcessorTx>) {
209    loop {
210        let env = tokio::select! {
211            biased;
212            Some(e) = rx.system.recv() => e,
213            Some(e) = rx.normal.recv() => e,
214            else => break,
215        };
216        forward_one(&dst, env).await;
217    }
218}
219
220async fn forward_one(dst: &Option<ProcessorTx>, env: Envelope) {
221    if let Some(tx) = dst {
222        tx.send(env).await;
223    }
224}
225
226/// Lifecycle frames whose fan-in must be synchronized across all branches.
227fn is_synced_lifecycle(frame: &Frame) -> bool {
228    matches!(
229        frame,
230        Frame::Start(_) | Frame::End { .. } | Frame::Cancel { .. }
231    )
232}
233
234/// Whether two lifecycle frames are "the same" event for sync counting.
235fn same_lifecycle(a: &Frame, b: &Frame) -> bool {
236    matches!(
237        (a, b),
238        (Frame::Start(_), Frame::Start(_))
239            | (Frame::End { .. }, Frame::End { .. })
240            | (Frame::Cancel { .. }, Frame::Cancel { .. })
241    )
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::error::Result;
248    use crate::processor::frame::{Direction, Frame};
249    use crate::processor::{Clock, Link};
250    use std::sync::atomic::{AtomicUsize, Ordering};
251    use std::sync::Mutex;
252    use std::time::Duration;
253
254    fn setup() -> ProcessorSetup {
255        ProcessorSetup {
256            clock: Clock::new(),
257            observer: None,
258            cancel: tokio_util::sync::CancellationToken::new(),
259            enable_metrics: false,
260            enable_usage_metrics: false,
261        }
262    }
263
264    // Counts how many Text frames it processed.
265    struct Counter {
266        name: &'static str,
267        count: Arc<AtomicUsize>,
268    }
269    #[async_trait]
270    impl FrameProcessor for Counter {
271        fn name(&self) -> &str {
272            self.name
273        }
274        async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
275            if matches!(env.frame, Frame::Text(_)) {
276                self.count.fetch_add(1, Ordering::Relaxed);
277            }
278            link.push(env.meta, env.frame, env.direction).await;
279            Ok(())
280        }
281    }
282
283    // A branch element that delays a configurable time on its terminal End, to
284    // exercise the lifecycle sync (a fast branch must wait for the slow one).
285    struct SlowEnd {
286        name: &'static str,
287        delay_ms: u64,
288        end_order: Arc<Mutex<Vec<&'static str>>>,
289    }
290    #[async_trait]
291    impl FrameProcessor for SlowEnd {
292        fn name(&self) -> &str {
293            self.name
294        }
295        async fn stop(&mut self, _r: crate::processor::StopReason) -> Result<()> {
296            if self.delay_ms > 0 {
297                tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
298            }
299            self.end_order.lock().unwrap().push(self.name);
300            Ok(())
301        }
302    }
303
304    #[tokio::test]
305    async fn frame_fans_to_n_branches_and_emits_once() {
306        let c1 = Arc::new(AtomicUsize::new(0));
307        let c2 = Arc::new(AtomicUsize::new(0));
308        let b1 = Pipeline::new(vec![Box::new(Counter {
309            name: "b1",
310            count: c1.clone(),
311        })]);
312        let b2 = Pipeline::new(vec![Box::new(Counter {
313            name: "b2",
314            count: c2.clone(),
315        })]);
316
317        let topo = into_topology(Box::new(ParallelPipeline::new(vec![b1, b2])));
318
319        // Capture the single de-duplicated downstream output.
320        let (out_tx, out_rx) = channel(Arc::from("out"), NORMAL_CHAN_CAP);
321        let st = setup();
322        let mut tasks = JoinSet::new();
323        let linked = link_topology(topo, None, Some(out_tx), &st, &mut tasks);
324
325        // One Text frame fanned to both branches.
326        linked
327            .head
328            .send(Envelope::new(
329                Frame::Text("x".into()),
330                Direction::Downstream,
331            ))
332            .await;
333
334        // It should emit exactly once downstream (deduped).
335        let mut out_rx = out_rx;
336        let first = tokio::time::timeout(Duration::from_secs(2), out_rx.normal.recv())
337            .await
338            .expect("timed out")
339            .expect("closed");
340        assert!(matches!(first.frame, Frame::Text(t) if t == "x"));
341        // No second copy within a short window.
342        let second = tokio::time::timeout(Duration::from_millis(200), out_rx.normal.recv()).await;
343        assert!(second.is_err(), "fanned frame must emit once, got a dup");
344
345        // Both branches processed it.
346        linked
347            .head
348            .send(Envelope::new(
349                Frame::End { reason: None },
350                Direction::Downstream,
351            ))
352            .await;
353        let _ = tokio::time::timeout(Duration::from_secs(2), async {
354            while tasks.join_next().await.is_some() {}
355        })
356        .await;
357        assert_eq!(c1.load(Ordering::Relaxed), 1);
358        assert_eq!(c2.load(Ordering::Relaxed), 1);
359    }
360
361    #[tokio::test]
362    async fn fast_branch_end_is_held_until_slow_branch_passes() {
363        let order = Arc::new(Mutex::new(Vec::new()));
364        let fast = Pipeline::new(vec![Box::new(SlowEnd {
365            name: "fast",
366            delay_ms: 0,
367            end_order: order.clone(),
368        })]);
369        let slow = Pipeline::new(vec![Box::new(SlowEnd {
370            name: "slow",
371            delay_ms: 150,
372            end_order: order.clone(),
373        })]);
374        let topo = into_topology(Box::new(ParallelPipeline::new(vec![fast, slow])));
375
376        let (out_tx, out_rx) = channel(Arc::from("out"), NORMAL_CHAN_CAP);
377        let st = setup();
378        let mut tasks = JoinSet::new();
379        let linked = link_topology(topo, None, Some(out_tx), &st, &mut tasks);
380
381        linked
382            .head
383            .send(Envelope::new(
384                Frame::End { reason: None },
385                Direction::Downstream,
386            ))
387            .await;
388
389        // The synchronized End should only reach `next` after BOTH branches'
390        // stop() ran — i.e. after the slow branch.
391        let mut out_rx = out_rx;
392        let got = tokio::time::timeout(Duration::from_secs(3), out_rx.normal.recv())
393            .await
394            .expect("End never synced")
395            .expect("closed");
396        assert!(matches!(got.frame, Frame::End { .. }));
397
398        let seq = order.lock().unwrap().clone();
399        assert!(
400            seq.contains(&"fast") && seq.contains(&"slow"),
401            "both branches must pass End, got {seq:?}"
402        );
403
404        let _ = tokio::time::timeout(Duration::from_secs(2), async {
405            while tasks.join_next().await.is_some() {}
406        })
407        .await;
408    }
409}