Skip to main content

pipecrab_runtime/
pipeline.rs

1//! [`Pipeline`]: a sequence of [`Stage`]s that is itself a [`Stage`].
2//!
3//! A pipeline *is* a stage ([`impl Stage for Pipeline`](Pipeline#impl-Stage-for-Pipeline)),
4//! so pipelines nest: add one to another builder with
5//! [`PipelineBuilder::stage`]. It reuses the same [`Inbound`] / [`Outbound`]
6//! abstraction every stage connects through — no bespoke channel types in the
7//! public surface.
8//!
9//! # Topology (v1)
10//!
11//! Both lanes form a linear downstream chain, wired at [`run`](Stage::run)
12//! time: the `inbound` handed to the pipeline feeds stage 0, each stage's
13//! output feeds the next via [`link`], and the tail's output is the pipeline's
14//! `out`. The `sys` lane is threaded through every stage the same way, so a
15//! control frame visits each stage in turn, and closing the input cascades
16//! shutdown from head to tail.
17//!
18//! Upstream routing of `Up`-travelling system frames *through* the stages is
19//! not yet wired: a [`Stage::perform`] error surfaces on the tail's output,
20//! tagged [`Direction::Up`](pipecrab_core::Direction::Up). That is a deliberate
21//! v1 limitation.
22//!
23//! # Driving it
24//!
25//! [`Pipeline::start`] wires fresh external [`PipelineEnds`] and hands back the
26//! driving future. The caller drives it — `block_on` natively, `spawn_local` in
27//! the browser; there is no spawning and no executor trait.
28
29use async_trait::async_trait;
30use futures::channel::mpsc;
31
32use futures::stream::{FuturesUnordered, StreamExt};
33use pipecrab_core::{DataFrame, Decision, Direction, Processor, SystemFrame};
34
35use crate::{Inbound, MaybeSend, Outbound, Stage, StageError};
36
37/// The boxed pipeline driver future returned by [`Pipeline::start`].
38///
39/// `Send` on native targets, so the driver can be handed to a multi-threaded
40/// executor (`tokio::spawn`); `!Send` on `wasm32`, where it is `spawn_local`-ed.
41#[cfg(not(target_arch = "wasm32"))]
42pub type DriverFuture = futures::future::BoxFuture<'static, ()>;
43/// The boxed pipeline driver future returned by [`Pipeline::start`].
44#[cfg(target_arch = "wasm32")]
45pub type DriverFuture = futures::future::LocalBoxFuture<'static, ()>;
46
47/// Default buffer depth for each lane channel: how many frames may queue on a
48/// lane before a send awaits (backpressure). Arbitrary-but-reasonable, not a
49/// convention; tune it with [`PipelineBuilder::capacity`].
50const DEFAULT_CAPACITY: usize = 16;
51
52/// Create a linked [`Outbound`] / [`Inbound`] pair sharing one data channel and
53/// one system channel: frames sent on the `Outbound` are received on the
54/// `Inbound`. This is the single wiring primitive — pipelines use it between
55/// adjacent stages and at their external ends.
56pub fn link(capacity: usize) -> (Outbound, Inbound) {
57    let capacity = capacity.max(1);
58    let (data_tx, data_rx) = mpsc::channel::<DataFrame>(capacity);
59    let (sys_tx, sys_rx) = mpsc::channel::<(Direction, SystemFrame)>(capacity);
60    (Outbound { data: data_tx, sys: sys_tx }, Inbound { sys: sys_rx, data: data_rx })
61}
62
63/// The external endpoints of a running [`Pipeline`]: send in via `input`, read
64/// out via `output` — the same [`Outbound`] / [`Inbound`] every stage uses.
65///
66/// Returned by [`Pipeline::start`]. Dropping `input` closes the head's inbound
67/// lanes, which cascades shutdown downstream and lets the driver finish.
68pub struct PipelineEnds {
69    /// Send data and system frames into the head of the pipeline.
70    pub input: Outbound,
71    /// Receive data and system frames emitted past the tail of the pipeline.
72    pub output: Inbound,
73}
74
75/// Builds a [`Pipeline`] from an ordered list of stages sharing one `Effect`.
76pub struct PipelineBuilder<E> {
77    stages: Vec<Box<dyn Stage<Effect = E>>>,
78    capacity: usize,
79}
80
81impl<E: MaybeSend + 'static> PipelineBuilder<E> {
82    /// A new, empty builder with the default lane capacity.
83    pub fn new() -> Self {
84        Self { stages: Vec::new(), capacity: DEFAULT_CAPACITY }
85    }
86
87    /// Override the per-lane buffer depth — how many frames may queue on a lane
88    /// before a send awaits (backpressure). Clamped to at least 1.
89    pub fn capacity(mut self, capacity: usize) -> Self {
90        self.capacity = capacity.max(1);
91        self
92    }
93
94    /// Append a stage, boxing it. Stages run in the order added, head first. A
95    /// [`Pipeline`] is itself a stage, so it may be passed here to nest.
96    pub fn stage(mut self, stage: impl Stage<Effect = E> + 'static) -> Self {
97        self.stages.push(Box::new(stage));
98        self
99    }
100
101    /// Append an already-boxed stage.
102    pub fn boxed(mut self, stage: Box<dyn Stage<Effect = E>>) -> Self {
103        self.stages.push(stage);
104        self
105    }
106
107    /// Finish building.
108    ///
109    /// # Panics
110    ///
111    /// Panics if no stages were added — a pipeline needs at least one stage.
112    pub fn build(self) -> Pipeline<E> {
113        assert!(!self.stages.is_empty(), "a pipeline needs at least one stage");
114        Pipeline { stages: self.stages, capacity: self.capacity }
115    }
116}
117
118impl<E: MaybeSend + 'static> Default for PipelineBuilder<E> {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124/// A sequence of stages, itself a [`Stage`]. Wired and driven by [`start`] (at
125/// the top level) or by a parent pipeline's [`run`](Stage::run) (when nested).
126///
127/// [`start`]: Pipeline::start
128pub struct Pipeline<E> {
129    stages: Vec<Box<dyn Stage<Effect = E>>>,
130    capacity: usize,
131}
132
133impl<E: MaybeSend + 'static> Pipeline<E> {
134    /// Wire fresh external [`PipelineEnds`] and return them with the driving
135    /// future. The caller drives the future (e.g. `block_on`) and uses the ends
136    /// to feed the head and read the tail.
137    pub fn start(self) -> (PipelineEnds, DriverFuture) {
138        let capacity = self.capacity;
139        let (input, head_in) = link(capacity);
140        let (tail_out, output) = link(capacity);
141        let driver = Box::new(self).run(head_in, tail_out);
142        (PipelineEnds { input, output }, driver)
143    }
144}
145
146// A pipeline's behavior lives entirely in its overridden `run`, which drives its
147// children. It is never treated as a leaf, so `decide_*` and `perform` are never
148// reached in correct use — they panic as tripwires for misuse (e.g. a custom
149// driver that calls the wrong method). `run` is the one legitimate entry point.
150impl<E> Processor for Pipeline<E> {
151    type Effect = E;
152
153    fn decide_data(&mut self, _frame: &DataFrame) -> Decision<E> {
154        unreachable!("a Pipeline is driven by Stage::run, not decide_data")
155    }
156
157    fn decide_system(&mut self, _dir: Direction, _frame: &SystemFrame) -> Decision<E> {
158        unreachable!("a Pipeline is driven by Stage::run, not decide_system")
159    }
160}
161
162#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
163#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
164impl<E: MaybeSend + 'static> Stage for Pipeline<E> {
165    async fn perform(&self, _effect: E, _out: &Outbound) -> Result<(), StageError> {
166        unreachable!("a Pipeline is driven by Stage::run, not perform")
167    }
168
169    /// Wire the children between `inbound` and `out` and drive every child's
170    /// `run` cooperatively as one future via [`FuturesUnordered`].
171    async fn run(self: Box<Self>, inbound: Inbound, out: Outbound) {
172        let Pipeline { stages, capacity } = *self;
173        let n = stages.len();
174        let mut tasks = FuturesUnordered::new();
175
176        // Thread `inbound` into stage 0 and `out` out of the last stage; link
177        // adjacent stages with fresh channels in between.
178        let mut current_in = Some(inbound);
179        let mut final_out = Some(out);
180        for (i, stage) in stages.into_iter().enumerate() {
181            let stage_in = current_in.take().expect("inbound threaded through");
182            let stage_out = if i + 1 == n {
183                final_out.take().expect("outbound threaded through")
184            } else {
185                let (this_out, next_in) = link(capacity);
186                current_in = Some(next_in);
187                this_out
188            };
189            tasks.push(stage.run(stage_in, stage_out));
190        }
191
192        while tasks.next().await.is_some() {}
193    }
194}