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, MaybeSendSync, 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    (
61        Outbound {
62            data: data_tx,
63            sys: sys_tx,
64        },
65        Inbound {
66            sys: sys_rx,
67            data: data_rx,
68        },
69    )
70}
71
72/// The external endpoints of a running [`Pipeline`]: send in via `input`, read
73/// out via `output` — the same [`Outbound`] / [`Inbound`] every stage uses.
74///
75/// Returned by [`Pipeline::start`]. Dropping `input` closes the head's inbound
76/// lanes, which cascades shutdown downstream and lets the driver finish.
77pub struct PipelineEnds {
78    /// Send data and system frames into the head of the pipeline.
79    pub input: Outbound,
80    /// Receive data and system frames emitted past the tail of the pipeline.
81    pub output: Inbound,
82}
83
84/// Type-erased runner for a stage stored in a pipeline.
85///
86/// Effects remain strongly typed inside each concrete [`Stage`]; the pipeline
87/// only needs to own and run the stage, so its storage does not expose that
88/// associated type.
89#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
90#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
91trait ErasedStage: MaybeSendSync {
92    async fn run(self: Box<Self>, inbound: Inbound, out: Outbound);
93}
94
95#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
96#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
97impl<S> ErasedStage for S
98where
99    S: Stage + 'static,
100    S::Effect: MaybeSend,
101{
102    async fn run(self: Box<Self>, inbound: Inbound, out: Outbound) {
103        Stage::run(self, inbound, out).await;
104    }
105}
106
107/// Adapts an already boxed stage to the pipeline's erased runner.
108struct BoxedStage<E: MaybeSend>(Box<dyn Stage<Effect = E>>);
109
110#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
111#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
112impl<E: MaybeSend + 'static> ErasedStage for BoxedStage<E> {
113    async fn run(self: Box<Self>, inbound: Inbound, out: Outbound) {
114        self.0.run(inbound, out).await;
115    }
116}
117
118/// Builds a [`Pipeline`] from an ordered list of stages.
119pub struct PipelineBuilder {
120    stages: Vec<Box<dyn ErasedStage>>,
121    capacity: usize,
122}
123
124impl PipelineBuilder {
125    /// A new, empty builder with the default lane capacity.
126    pub fn new() -> Self {
127        Self {
128            stages: Vec::new(),
129            capacity: DEFAULT_CAPACITY,
130        }
131    }
132
133    /// Override the per-lane buffer depth — how many frames may queue on a lane
134    /// before a send awaits (backpressure). Clamped to at least 1.
135    pub fn capacity(mut self, capacity: usize) -> Self {
136        self.capacity = capacity.max(1);
137        self
138    }
139
140    /// Append a stage, boxing it. Stages run in the order added, head first. A
141    /// [`Pipeline`] is itself a stage, so it may be passed here to nest.
142    pub fn stage<S>(mut self, stage: S) -> Self
143    where
144        S: Stage + 'static,
145        S::Effect: MaybeSend,
146    {
147        self.stages.push(Box::new(stage));
148        self
149    }
150
151    /// Append an already-boxed stage.
152    pub fn boxed<E: MaybeSend + 'static>(mut self, stage: Box<dyn Stage<Effect = E>>) -> Self {
153        self.stages.push(Box::new(BoxedStage(stage)));
154        self
155    }
156
157    /// Finish building.
158    ///
159    /// # Panics
160    ///
161    /// Panics if no stages were added — a pipeline needs at least one stage.
162    pub fn build(self) -> Pipeline {
163        assert!(
164            !self.stages.is_empty(),
165            "a pipeline needs at least one stage"
166        );
167        Pipeline {
168            stages: self.stages,
169            capacity: self.capacity,
170        }
171    }
172}
173
174impl Default for PipelineBuilder {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180/// A sequence of stages, itself a [`Stage`]. Wired and driven by [`start`] (at
181/// the top level) or by a parent pipeline's [`run`](Stage::run) (when nested).
182///
183/// [`start`]: Pipeline::start
184pub struct Pipeline {
185    stages: Vec<Box<dyn ErasedStage>>,
186    capacity: usize,
187}
188
189impl Pipeline {
190    /// Wire fresh external [`PipelineEnds`] and return them with the driving
191    /// future. The caller drives the future (e.g. `block_on`) and uses the ends
192    /// to feed the head and read the tail.
193    pub fn start(self) -> (PipelineEnds, DriverFuture) {
194        let capacity = self.capacity;
195        let (input, head_in) = link(capacity);
196        let (tail_out, output) = link(capacity);
197        let driver = Stage::run(Box::new(self), head_in, tail_out);
198        (PipelineEnds { input, output }, driver)
199    }
200}
201
202// A pipeline's behavior lives entirely in its overridden `run`, which drives its
203// children. It is never treated as a leaf, so `decide_*` and `perform` are never
204// reached in correct use — they panic as tripwires for misuse (e.g. a custom
205// driver that calls the wrong method). `run` is the one legitimate entry point.
206impl Processor for Pipeline {
207    type Effect = ();
208
209    fn decide_data(&mut self, _frame: &DataFrame) -> Decision<()> {
210        unreachable!("a Pipeline is driven by Stage::run, not decide_data")
211    }
212
213    fn decide_system(&mut self, _dir: Direction, _frame: &SystemFrame) -> Decision<()> {
214        unreachable!("a Pipeline is driven by Stage::run, not decide_system")
215    }
216}
217
218#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
219#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
220impl Stage for Pipeline {
221    async fn perform(&self, _effect: (), _out: &Outbound) -> Result<(), StageError> {
222        unreachable!("a Pipeline is driven by Stage::run, not perform")
223    }
224
225    /// Wire the children between `inbound` and `out` and drive every child's
226    /// `run` cooperatively as one future via [`FuturesUnordered`].
227    async fn run(self: Box<Self>, inbound: Inbound, out: Outbound) {
228        let Pipeline { stages, capacity } = *self;
229        let n = stages.len();
230        let mut tasks = FuturesUnordered::new();
231
232        // Thread `inbound` into stage 0 and `out` out of the last stage; link
233        // adjacent stages with fresh channels in between.
234        let mut current_in = Some(inbound);
235        let mut final_out = Some(out);
236        for (i, stage) in stages.into_iter().enumerate() {
237            let stage_in = current_in.take().expect("inbound threaded through");
238            let stage_out = if i + 1 == n {
239                final_out.take().expect("outbound threaded through")
240            } else {
241                let (this_out, next_in) = link(capacity);
242                current_in = Some(next_in);
243                this_out
244            };
245            tasks.push(ErasedStage::run(stage, stage_in, stage_out));
246        }
247
248        while tasks.next().await.is_some() {}
249    }
250}