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}