use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::task::JoinSet;
use crate::processor::frame::Frame;
use crate::processor::runtime::{channel, ProcessorRx, ProcessorTx, NORMAL_CHAN_CAP};
use crate::processor::{into_topology, Envelope, FrameProcessor, ProcessorSetup, Topology};
use super::{link_topology, Linked, Pipeline};
pub struct ParallelPipeline {
branches: Vec<Pipeline>,
}
impl ParallelPipeline {
pub fn new(branches: Vec<Pipeline>) -> Self {
Self { branches }
}
pub fn len(&self) -> usize {
self.branches.len()
}
pub fn is_empty(&self) -> bool {
self.branches.is_empty()
}
}
#[async_trait]
impl FrameProcessor for ParallelPipeline {
fn name(&self) -> &str {
"ParallelPipeline"
}
fn explode(&mut self) -> Option<Topology> {
let branches = std::mem::take(&mut self.branches);
Some(Topology::Parallel(
branches
.into_iter()
.map(|b| into_topology(Box::new(b)))
.collect(),
))
}
}
pub(crate) fn link_parallel(
branches: Vec<Topology>,
prev: Option<ProcessorTx>,
next: Option<ProcessorTx>,
setup: &ProcessorSetup,
tasks: &mut JoinSet<()>,
) -> Linked {
let branch_count = branches.len().max(1);
let (head_tx, head_rx) = channel(Arc::from("ParallelIn"), NORMAL_CHAN_CAP);
let (tail_tx, tail_rx) = channel(Arc::from("ParallelOut"), NORMAL_CHAN_CAP);
let (fanin_tx, fanin_rx) = channel(Arc::from("ParallelFanIn"), NORMAL_CHAN_CAP);
let (upstream_tx, upstream_rx) = channel(Arc::from("ParallelUpstream"), NORMAL_CHAN_CAP);
let mut branch_heads: Vec<ProcessorTx> = Vec::with_capacity(branch_count);
for branch in branches {
let linked = link_topology(
branch,
Some(upstream_tx.clone()),
Some(fanin_tx.clone()),
setup,
tasks,
);
branch_heads.push(linked.head);
}
drop(fanin_tx);
drop(upstream_tx);
tasks.spawn(fan_out(head_rx, branch_heads));
tasks.spawn(fan_in(fanin_rx, next, branch_count));
tasks.spawn(forward(upstream_rx, prev));
tasks.spawn(forward(tail_rx, None));
Linked {
head: head_tx,
tail: tail_tx,
}
}
async fn fan_out(mut rx: ProcessorRx, branch_heads: Vec<ProcessorTx>) {
loop {
let env = tokio::select! {
biased;
Some(e) = rx.system.recv() => e,
Some(e) = rx.normal.recv() => e,
else => break,
};
let terminal = env.frame.is_terminal();
for head in &branch_heads {
head.send(env.clone()).await;
}
if terminal {
break;
}
}
}
async fn fan_in(mut rx: ProcessorRx, next: Option<ProcessorTx>, branch_count: usize) {
let mut seen_ids: HashSet<u64> = HashSet::new();
let mut lifecycle_pending: Option<(Frame, usize)> = None;
let mut buffer: Vec<Envelope> = Vec::new();
loop {
let env = tokio::select! {
biased;
Some(e) = rx.system.recv() => e,
Some(e) = rx.normal.recv() => e,
else => break,
};
if is_synced_lifecycle(&env.frame) {
match &mut lifecycle_pending {
Some((pending, count)) if same_lifecycle(pending, &env.frame) => {
*count += 1;
if *count >= branch_count {
forward_one(&next, env).await;
for buffered in buffer.drain(..) {
forward_one(&next, buffered).await;
}
lifecycle_pending = None;
}
}
Some(_) => {
buffer.push(env);
}
None => {
lifecycle_pending = Some((env.frame.clone(), 1));
if branch_count <= 1 {
forward_one(&next, env).await;
lifecycle_pending = None;
}
}
}
continue;
}
if !seen_ids.insert(env.meta.id) {
continue; }
if lifecycle_pending.is_some() {
buffer.push(env);
} else {
forward_one(&next, env).await;
}
}
}
async fn forward(mut rx: ProcessorRx, dst: Option<ProcessorTx>) {
loop {
let env = tokio::select! {
biased;
Some(e) = rx.system.recv() => e,
Some(e) = rx.normal.recv() => e,
else => break,
};
forward_one(&dst, env).await;
}
}
async fn forward_one(dst: &Option<ProcessorTx>, env: Envelope) {
if let Some(tx) = dst {
tx.send(env).await;
}
}
fn is_synced_lifecycle(frame: &Frame) -> bool {
matches!(
frame,
Frame::Start(_) | Frame::End { .. } | Frame::Cancel { .. }
)
}
fn same_lifecycle(a: &Frame, b: &Frame) -> bool {
matches!(
(a, b),
(Frame::Start(_), Frame::Start(_))
| (Frame::End { .. }, Frame::End { .. })
| (Frame::Cancel { .. }, Frame::Cancel { .. })
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Result;
use crate::processor::frame::{Direction, Frame};
use crate::processor::{Clock, Link};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::Duration;
fn setup() -> ProcessorSetup {
ProcessorSetup {
clock: Clock::new(),
observer: None,
cancel: tokio_util::sync::CancellationToken::new(),
enable_metrics: false,
enable_usage_metrics: false,
}
}
struct Counter {
name: &'static str,
count: Arc<AtomicUsize>,
}
#[async_trait]
impl FrameProcessor for Counter {
fn name(&self) -> &str {
self.name
}
async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
if matches!(env.frame, Frame::Text(_)) {
self.count.fetch_add(1, Ordering::Relaxed);
}
link.push(env.meta, env.frame, env.direction).await;
Ok(())
}
}
struct SlowEnd {
name: &'static str,
delay_ms: u64,
end_order: Arc<Mutex<Vec<&'static str>>>,
}
#[async_trait]
impl FrameProcessor for SlowEnd {
fn name(&self) -> &str {
self.name
}
async fn stop(&mut self, _r: crate::processor::StopReason) -> Result<()> {
if self.delay_ms > 0 {
tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
}
self.end_order.lock().unwrap().push(self.name);
Ok(())
}
}
#[tokio::test]
async fn frame_fans_to_n_branches_and_emits_once() {
let c1 = Arc::new(AtomicUsize::new(0));
let c2 = Arc::new(AtomicUsize::new(0));
let b1 = Pipeline::new(vec![Box::new(Counter {
name: "b1",
count: c1.clone(),
})]);
let b2 = Pipeline::new(vec![Box::new(Counter {
name: "b2",
count: c2.clone(),
})]);
let topo = into_topology(Box::new(ParallelPipeline::new(vec![b1, b2])));
let (out_tx, out_rx) = channel(Arc::from("out"), NORMAL_CHAN_CAP);
let st = setup();
let mut tasks = JoinSet::new();
let linked = link_topology(topo, None, Some(out_tx), &st, &mut tasks);
linked
.head
.send(Envelope::new(
Frame::Text("x".into()),
Direction::Downstream,
))
.await;
let mut out_rx = out_rx;
let first = tokio::time::timeout(Duration::from_secs(2), out_rx.normal.recv())
.await
.expect("timed out")
.expect("closed");
assert!(matches!(first.frame, Frame::Text(t) if t == "x"));
let second = tokio::time::timeout(Duration::from_millis(200), out_rx.normal.recv()).await;
assert!(second.is_err(), "fanned frame must emit once, got a dup");
linked
.head
.send(Envelope::new(
Frame::End { reason: None },
Direction::Downstream,
))
.await;
let _ = tokio::time::timeout(Duration::from_secs(2), async {
while tasks.join_next().await.is_some() {}
})
.await;
assert_eq!(c1.load(Ordering::Relaxed), 1);
assert_eq!(c2.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn fast_branch_end_is_held_until_slow_branch_passes() {
let order = Arc::new(Mutex::new(Vec::new()));
let fast = Pipeline::new(vec![Box::new(SlowEnd {
name: "fast",
delay_ms: 0,
end_order: order.clone(),
})]);
let slow = Pipeline::new(vec![Box::new(SlowEnd {
name: "slow",
delay_ms: 150,
end_order: order.clone(),
})]);
let topo = into_topology(Box::new(ParallelPipeline::new(vec![fast, slow])));
let (out_tx, out_rx) = channel(Arc::from("out"), NORMAL_CHAN_CAP);
let st = setup();
let mut tasks = JoinSet::new();
let linked = link_topology(topo, None, Some(out_tx), &st, &mut tasks);
linked
.head
.send(Envelope::new(
Frame::End { reason: None },
Direction::Downstream,
))
.await;
let mut out_rx = out_rx;
let got = tokio::time::timeout(Duration::from_secs(3), out_rx.normal.recv())
.await
.expect("End never synced")
.expect("closed");
assert!(matches!(got.frame, Frame::End { .. }));
let seq = order.lock().unwrap().clone();
assert!(
seq.contains(&"fast") && seq.contains(&"slow"),
"both branches must pass End, got {seq:?}"
);
let _ = tokio::time::timeout(Duration::from_secs(2), async {
while tasks.join_next().await.is_some() {}
})
.await;
}
}