spate_core/ops/mod.rs
1//! Operator chain: statically composed push stages behind one type-erasure
2//! boundary per batch.
3//!
4//! Stages compose via [`Collector`] (monomorphized, so a whole chain compiles
5//! to one loop); the only virtual call on the data path is
6//! [`RunnableChain::push_batch`], once per poll batch. Records are born
7//! (deserialized) and die (encoded into shard frames, filtered, or skipped)
8//! inside a single `push_batch` call, so borrowed payloads never cross or
9//! outlive the boundary (ADR-0013).
10//!
11//! # Owned vs borrowed record families
12//!
13//! For owned families ([`Owned<T>`](crate::deser::Owned)) the builder
14//! offers [`ChainBuilder::map`] / [`ChainBuilder::try_map`] with plain
15//! closure bounds; bare closures infer. For **borrowing** families a
16//! `rustc` limitation (E0582: a higher-ranked lifetime may not appear only
17//! in associated-type positions) rules out `FnMut`-with-projection-output
18//! bounds at the definition site; use [`ChainBuilder::map_rec`] /
19//! [`ChainBuilder::try_map_rec`], whose bound goes through [`MapFn`] /
20//! [`TryMapFn`]. Pass a **`fn` item** where you can: it satisfies a
21//! higher-ranked bound by construction, where a closure satisfies it only
22//! when the compiler infers a higher-ranked signature for it.
23//!
24//! A stage over a borrowing family, written as a `fn` item:
25//!
26//! ```
27//! # use spate_core::checkpoint::AckRef;
28//! # use spate_core::deser::{Deserializer, EmitRecord, RecFamily};
29//! # use spate_core::error::DeserError;
30//! # use spate_core::ops::chain;
31//! # use spate_core::record::RawPayload;
32//! # struct LogEvent<'buf> {
33//! # key: &'buf str,
34//! # }
35//! # struct LogF;
36//! # impl RecFamily for LogF {
37//! # type Rec<'buf> = LogEvent<'buf>;
38//! # }
39//! # #[derive(Clone, Default)]
40//! # struct LogDeser;
41//! # impl Deserializer<LogF> for LogDeser {
42//! # fn deserialize<'buf>(
43//! # &mut self,
44//! # raw: &RawPayload<'buf>,
45//! # ack: &AckRef,
46//! # out: &mut dyn EmitRecord<'buf, LogEvent<'buf>>,
47//! # ) -> Result<(), DeserError> {
48//! # let _ = (raw, ack, out);
49//! # Ok(())
50//! # }
51//! # }
52//! # let log_deser = LogDeser;
53//! struct Compact<'buf> {
54//! key: &'buf str,
55//! }
56//! struct CompactF;
57//! impl RecFamily for CompactF {
58//! type Rec<'buf> = Compact<'buf>;
59//! }
60//!
61//! fn shrink<'a>(e: LogEvent<'a>) -> Compact<'a> {
62//! Compact { key: e.key }
63//! }
64//! let stage = chain(log_deser).map_rec::<CompactF, _>(shrink);
65//! # let _ = stage;
66//! ```
67//!
68//! [`ChainBuilder::filter`], [`ChainBuilder::inspect`], and
69//! [`ChainBuilder::flat_map`] have no output binding, so a single generic
70//! method serves both kinds of family.
71//!
72//! ```
73//! use spate_core::backpressure::InflightBudget;
74//! use spate_core::deser::{BytesPassthrough, Owned};
75//! use spate_core::error::ErrorPolicy;
76//! use spate_core::ops::{ChunkConfig, chain};
77//! use spate_core::record::Record;
78//! use spate_core::sink::{KeyHashRouter, RowEncoder, shard_queues};
79//! use std::sync::Arc;
80//!
81//! // A trivial encoder writing `<u32 len><bytes>` rows.
82//! #[derive(Clone)]
83//! struct LenPrefix;
84//! impl RowEncoder<Owned<Vec<u8>>> for LenPrefix {
85//! fn encode<'buf>(
86//! &mut self,
87//! rec: &Record<Vec<u8>>,
88//! buf: &mut bytes::BytesMut,
89//! ) -> Result<(), spate_core::error::SinkError> {
90//! buf.extend_from_slice(&(rec.payload.len() as u32).to_le_bytes());
91//! buf.extend_from_slice(&rec.payload);
92//! Ok(())
93//! }
94//! }
95//!
96//! let (queues, _rx) = shard_queues(2, 64);
97//! let budget = Arc::new(InflightBudget::new());
98//!
99//! let mut pipeline_chain = chain(BytesPassthrough)
100//! .map(|mut bytes: Vec<u8>| {
101//! bytes.make_ascii_uppercase();
102//! bytes
103//! })
104//! .filter(|bytes: &Vec<u8>| !bytes.is_empty())
105//! .try_map(
106//! |bytes: Vec<u8>| String::from_utf8(bytes).map(String::into_bytes),
107//! ErrorPolicy::Skip,
108//! )
109//! .sink(LenPrefix, KeyHashRouter, ChunkConfig::default(), queues, budget)
110//! .build();
111//! # let _ = &mut pipeline_chain;
112//! ```
113
114mod builder;
115mod chain;
116mod handoff;
117mod split;
118#[cfg(test)]
119mod tests;
120
121pub use builder::{
122 Assemble, ChainBuilder, ChainFactory, FilterPart, FlatMapPart, InspectPart, MapFn, MapPart,
123 Root, RoutedSplit, SinkedChain, SplitBuilder, TryMapFn, TryMapPart, chain, chain_owned,
124};
125pub use chain::{Emitter, Filter, FlatMap, Inspect, Map, StageLifecycle, TryMap, TypedChain};
126pub use handoff::{ChunkConfig, SinkHandoff};
127pub use split::{Sink, SinkCtx, SplitEmitter, SplitTerminal};
128
129use crate::deser::RecFamily;
130use crate::error::FatalError;
131use crate::record::{Flow, Record};
132use crate::source::PayloadBatch;
133
134/// Why a batch could not complete yet. Both cases are retried with the
135/// resume cursor, but only [`BlockReason::Capacity`] engages the driver's
136/// backpressure controller. A not-ready wait is an upstream dependency
137/// (e.g. a schema fetch), not sink pressure, and pausing the source for it
138/// would misreport the pipeline's state.
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140#[non_exhaustive]
141pub enum BlockReason {
142 /// The terminal stage could not accept more output (a shard queue is
143 /// full). This is sink backpressure.
144 Capacity,
145 /// A deserializer reported
146 /// [`DeserError::NotReady`](crate::error::DeserError::NotReady): the
147 /// payload replays once its dependency arrives. Counted on
148 /// `spate_deser_not_ready_total`.
149 NotReady,
150}
151
152/// Result of pushing one batch (or a resumed suffix of one) through a
153/// chain.
154#[derive(Debug)]
155#[non_exhaustive]
156pub enum PushOutcome {
157 /// Every payload was fully processed (records may have been filtered
158 /// or skipped by policy along the way).
159 Done,
160 /// The batch could not complete yet. Payloads with index `< resume_at`
161 /// are fully processed; the driver later re-pushes the same batch with
162 /// `from = resume_at`. Any partially-emitted payload's already-emitted
163 /// records are parked inside the terminal stage and drain first on
164 /// resume; operators never re-run for them.
165 Blocked {
166 /// Index of the first payload not yet fully processed.
167 resume_at: usize,
168 /// What the batch is waiting for.
169 reason: BlockReason,
170 },
171 /// A `Fail`-policy stage tripped or an invariant broke. The batch's
172 /// [`AckRef`](crate::checkpoint::AckRef) must be failed by the driver;
173 /// the pipeline stops.
174 Fatal(FatalError),
175}
176
177/// The one erasure boundary between a pipeline thread's driver loop and a
178/// typed chain. The methods are generic over the buffer lifetime
179/// only, so `Box<dyn RunnableChain>` is legal.
180pub trait RunnableChain: Send {
181 /// Push payloads `from..` of `batch` through the chain.
182 fn push_batch<'buf>(&mut self, batch: &mut dyn PayloadBatch<'buf>, from: usize) -> PushOutcome;
183
184 /// Flush terminal-stage state (parked records, partial encoder
185 /// buffers) downstream. Called by the driver on drain, on linger
186 /// deadlines, and before commit ticks.
187 fn flush(&mut self) -> PushOutcome;
188
189 /// Discard any per-batch replay/resume state after the driver failed the
190 /// current batch's acknowledgment (a shutdown-time abandonment of a
191 /// batch blocked mid-push). Terminal parked chunks (which carry their
192 /// own acks) are unaffected; only the chain's own mid-batch cursor and
193 /// any stashed not-ready payload are cleared, so the next `push_batch` of
194 /// a fresh batch starts clean instead of tripping the resume-cursor
195 /// asserts or replaying the stale payload under the new batch's ack.
196 ///
197 /// The default is a no-op for chains that keep no cross-call batch state.
198 fn abandon_batch(&mut self) {}
199}
200
201/// Push-model stage: receives one record, forwards 0..N downstream.
202///
203/// Composed statically; `Map<F, Filter<P, Term>>` monomorphizes into a
204/// single inlined loop body.
205pub trait Collector<T> {
206 /// Push one record. [`Flow::Blocked`] propagates up to the boundary.
207 fn push(&mut self, rec: Record<T>) -> Flow;
208}
209
210/// Family-erased collector: accepts the family's record type at *any*
211/// buffer lifetime through a lifetime-generic method, which keeps it
212/// dyn-compatible. This is what lets `flat_map` closures hold a plain
213/// `&mut Emitter<'_, OutF>` without naming the downstream stack type.
214pub trait CollectorFor<F: RecFamily> {
215 /// Push one record of the family at any lifetime.
216 fn push_rec<'buf>(&mut self, rec: Record<F::Rec<'buf>>) -> Flow;
217}
218
219impl<F, C> CollectorFor<F> for C
220where
221 F: RecFamily,
222 C: for<'buf> Collector<<F as RecFamily>::Rec<'buf>>,
223{
224 #[inline(always)]
225 fn push_rec<'buf>(&mut self, rec: Record<F::Rec<'buf>>) -> Flow {
226 self.push(rec)
227 }
228}