Skip to main content

spate_core/ops/
split.rs

1//! The split terminal: route each record to exactly one of N typed sink
2//! branches, each with its own schema, encoder, router, and shard queues.
3//!
4//! Where [`SinkHandoff`](super::handoff::SinkHandoff) is one table, the split
5//! terminal fans a heterogeneously-typed stream out across many. The user
6//! writes a single `match` (classify + extract in the same arm) and dispatches
7//! with [`SplitEmitter::emit`]; a record that reaches no branch follows the
8//! configured [`ErrorPolicy`] (`Fail` — the default — stops the pipeline;
9//! `Skip` drops it and counts `spate_operator_records_dropped_total{reason="unrouted"}`).
10//!
11//! # How the typed dispatch stays cheap and object-safe
12//!
13//! Each branch is a [`SinkHandoff<F, BoxedEncoder<F>, BoxedRouter<F>>`](super::handoff::SinkHandoff)
14//! — its encoder and router are erased so the branch's concrete type depends
15//! only on the destination family `F`. A [`Sink<F>`] handle (a plain index plus
16//! `F`) therefore names the exact concrete type, so [`SplitEmitter::emit`]
17//! recovers it with one `Any` downcast, then routes and encodes through the
18//! branch's boxed router/encoder — one virtual call each per record over the
19//! single-sink path's concrete types — straight into that branch's per-shard
20//! buffer.
21//! The at-least-once machinery is inherited unchanged: each branch clones the
22//! poll batch's [`AckRef`](crate::checkpoint::AckRef) into its own fail-on-drop
23//! `AckSet`, so the source watermark holds until *every* branch that received a
24//! derived record has durably written, and any branch's failure stalls it.
25
26use super::Collector;
27use super::chain::{FatalSlot, OpMeterSlot, StageLifecycle};
28use super::handoff::{ChunkConfig, SinkHandoff};
29use crate::backpressure::InflightBudget;
30use crate::checkpoint::AckRef;
31use crate::deser::RecFamily;
32use crate::error::{ErrorPolicy, FatalError, SinkError};
33use crate::record::{Flow, Record, RecordMeta};
34use crate::sink::{RecordRouter, RowEncoder, ShardQueues};
35use bytes::BytesMut;
36use std::any::Any;
37use std::marker::PhantomData;
38use std::sync::Arc;
39use std::time::Duration;
40
41/// The per-sink handles a split branch needs, resolved by name from the
42/// chain factory's [`ChainCtx`](crate::pipeline::ChainCtx) via
43/// [`ChainCtx::sink`](crate::pipeline::ChainCtx::sink). Bundling the name in
44/// keeps [`SplitBuilder::add`](super::ChainBuilder) from repeating it.
45#[derive(Clone, Debug)]
46#[non_exhaustive]
47pub struct SinkCtx {
48    pub(crate) name: String,
49    pub(crate) queues: ShardQueues,
50    pub(crate) budget: Arc<InflightBudget>,
51    /// This branch's resolved terminal-stage chunking (its per-sink YAML
52    /// `chunk:` block, `SinkOptions::with_chunk`, or the default), applied by
53    /// [`SplitBuilder::add`](super::ChainBuilder).
54    pub(crate) chunk: ChunkConfig,
55}
56
57impl SinkCtx {
58    /// Bundle a named sink's queues and the shared in-flight budget. Chunking
59    /// starts at [`ChunkConfig::default`]; override it with
60    /// [`with_chunk`](Self::with_chunk). (Builder pipelines never call this —
61    /// [`ChainCtx::sink`](crate::pipeline::ChainCtx::sink) hands out a fully
62    /// resolved `SinkCtx`.)
63    #[must_use]
64    pub fn new(name: String, queues: ShardQueues, budget: Arc<InflightBudget>) -> Self {
65        SinkCtx {
66            name,
67            queues,
68            budget,
69            chunk: ChunkConfig::default(),
70        }
71    }
72
73    /// Set this branch's terminal-stage chunking — the manual-assembly
74    /// counterpart to the per-sink YAML `chunk:` block.
75    #[must_use]
76    pub fn with_chunk(mut self, chunk: ChunkConfig) -> Self {
77        self.chunk = chunk;
78        self
79    }
80}
81
82/// A typed, `Copy` handle to one split branch — a branch index plus the
83/// destination family, so [`SplitEmitter::emit`] both type-checks the row and
84/// recovers the branch with zero per-call lookup. Minted by
85/// [`SplitBuilder::add`](super::ChainBuilder).
86pub struct Sink<F: RecFamily> {
87    idx: usize,
88    _f: PhantomData<fn() -> F>,
89}
90
91impl<F: RecFamily> Sink<F> {
92    pub(crate) fn new(idx: usize) -> Self {
93        Sink {
94            idx,
95            _f: PhantomData,
96        }
97    }
98}
99
100impl<F: RecFamily> Clone for Sink<F> {
101    fn clone(&self) -> Self {
102        *self
103    }
104}
105
106impl<F: RecFamily> Copy for Sink<F> {}
107
108impl<F: RecFamily> std::fmt::Debug for Sink<F> {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("Sink").field("idx", &self.idx).finish()
111    }
112}
113
114// ── Type-erased encoder/router so a branch's concrete type keys on `F` ──────
115
116/// A [`RowEncoder`] that can clone itself into a box, so a boxed encoder is
117/// still `Clone` (the terminal stage mints one encoder per shard).
118trait EncoderClone<F: RecFamily>: RowEncoder<F> {
119    /// Clone into a fresh box.
120    fn clone_box(&self) -> Box<dyn EncoderClone<F>>;
121}
122
123impl<F: RecFamily, T> EncoderClone<F> for T
124where
125    T: RowEncoder<F> + Clone + 'static,
126{
127    fn clone_box(&self) -> Box<dyn EncoderClone<F>> {
128        Box::new(self.clone())
129    }
130}
131
132/// A branch's encoder, erased to depend only on the destination family.
133type BoxedEncoder<F> = Box<dyn EncoderClone<F>>;
134
135impl<F: RecFamily> Clone for BoxedEncoder<F> {
136    fn clone(&self) -> Self {
137        // Dispatch through the trait object to the *concrete* encoder's
138        // `clone_box`. `self.clone_box()` would re-select the blanket impl
139        // (which also covers `Box<dyn EncoderClone>`) and recurse into this
140        // very `clone` — infinitely. `(**self)` pins it to the vtable.
141        (**self).clone_box()
142    }
143}
144
145impl<F: RecFamily> RowEncoder<F> for BoxedEncoder<F> {
146    fn encode<'buf>(
147        &mut self,
148        rec: &Record<F::Rec<'buf>>,
149        buf: &mut BytesMut,
150    ) -> Result<(), SinkError> {
151        (**self).encode(rec, buf)
152    }
153
154    fn buffered_bytes(&self) -> usize {
155        (**self).buffered_bytes()
156    }
157
158    fn finish_chunk(&mut self, buf: &mut BytesMut) -> Result<(), SinkError> {
159        (**self).finish_chunk(buf)
160    }
161}
162
163/// A branch's router, erased to depend only on the destination family.
164type BoxedRouter<F> = Box<dyn RecordRouter<F>>;
165
166impl<F: RecFamily> RecordRouter<F> for BoxedRouter<F> {
167    fn route_record<'buf>(&self, rec: &Record<F::Rec<'buf>>, num_shards: usize) -> usize {
168        (**self).route_record(rec, num_shards)
169    }
170}
171
172/// A branch's concrete type: a [`SinkHandoff`] over the erased encoder/router,
173/// determined by the destination family alone.
174type Branch<F> = SinkHandoff<F, BoxedEncoder<F>, BoxedRouter<F>>;
175
176// ── Object-safe branch storage ──────────────────────────────────────────────
177
178/// The lifecycle a split branch exposes to the terminal, plus `Any` recovery
179/// for the typed emit path. Object-safe (no destination-family type appears in
180/// the signatures), so branches of different families live in one `Vec`.
181pub(crate) trait ErasedBranch: Send {
182    fn relieve(&mut self) -> Flow;
183    fn flush_terminal(&mut self) -> Flow;
184    fn take_fatal(&mut self) -> Option<FatalError>;
185    fn on_batch_end(&mut self, elapsed: Duration);
186    fn as_any_mut(&mut self) -> &mut dyn Any;
187}
188
189impl<F, E, R> ErasedBranch for SinkHandoff<F, E, R>
190where
191    F: RecFamily + 'static,
192    E: RowEncoder<F> + Clone + 'static,
193    R: RecordRouter<F> + 'static,
194{
195    fn relieve(&mut self) -> Flow {
196        StageLifecycle::relieve(self)
197    }
198
199    fn flush_terminal(&mut self) -> Flow {
200        StageLifecycle::flush_terminal(self)
201    }
202
203    fn take_fatal(&mut self) -> Option<FatalError> {
204        StageLifecycle::take_fatal(self)
205    }
206
207    fn on_batch_end(&mut self, elapsed: Duration) {
208        StageLifecycle::on_batch_end(self, elapsed);
209    }
210
211    fn as_any_mut(&mut self) -> &mut dyn Any {
212        self
213    }
214}
215
216/// Build one erased branch from a concrete encoder/router pair.
217pub(crate) fn new_branch<F, E, R>(
218    encoder: E,
219    router: R,
220    queues: ShardQueues,
221    budget: Arc<InflightBudget>,
222    cfg: ChunkConfig,
223    meter: OpMeterSlot,
224    component: Arc<str>,
225) -> Box<dyn ErasedBranch>
226where
227    F: RecFamily + 'static,
228    E: RowEncoder<F> + Clone + Send + 'static,
229    R: RecordRouter<F> + 'static,
230{
231    let encoder: BoxedEncoder<F> = Box::new(encoder);
232    let router: BoxedRouter<F> = Box::new(router);
233    let handoff: Branch<F> =
234        SinkHandoff::new(encoder, router, queues, budget, cfg, meter, component);
235    Box::new(handoff)
236}
237
238// ── The stack-borrowed emitter ──────────────────────────────────────────────
239
240/// Stack-borrowed emitter handed to a [`route`](super::ChainBuilder) closure.
241/// [`emit`](Self::emit) routes one derived record to the branch named by a
242/// [`Sink<F>`] handle; a record that emits to no branch triggers the split's
243/// `unmatched` policy. One `Any` downcast per emit, no per-call name lookup.
244pub struct SplitEmitter<'a> {
245    branches: &'a mut [Box<dyn ErasedBranch>],
246    meta: RecordMeta,
247    ack: &'a AckRef,
248    emitted: u32,
249    flow: Flow,
250}
251
252impl std::fmt::Debug for SplitEmitter<'_> {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        f.debug_struct("SplitEmitter")
255            .field("emitted", &self.emitted)
256            .field("flow", &self.flow)
257            .finish_non_exhaustive()
258    }
259}
260
261impl SplitEmitter<'_> {
262    /// Route one derived record to `handle`'s branch, inheriting the parent's
263    /// metadata and acknowledgement handle. Emitting to no branch (returning
264    /// from the closure without any `emit`) invokes the `unmatched` policy.
265    ///
266    /// # Panics
267    ///
268    /// Panics if `handle` does not name a branch of this split — a handle
269    /// minted by a different split's builder, or one whose record family
270    /// does not match the branch at its index.
271    #[inline]
272    pub fn emit<'buf, F: RecFamily + 'static>(&mut self, handle: Sink<F>, row: F::Rec<'buf>) {
273        let branch = self
274            .branches
275            .get_mut(handle.idx)
276            .and_then(|b| b.as_any_mut().downcast_mut::<Branch<F>>())
277            .expect(
278                "split branch/handle mismatch: this Sink<F> handle does not name a \
279                 branch of this split (a handle from another split, or the wrong \
280                 record family)",
281            );
282        let flow = branch.push(Record {
283            payload: row,
284            meta: self.meta,
285            ack: self.ack.clone(),
286        });
287        self.emitted += 1;
288        if self.flow != Flow::Blocked {
289            self.flow = flow;
290        }
291    }
292
293    /// The parent record's metadata.
294    #[must_use]
295    pub fn meta(&self) -> RecordMeta {
296        self.meta
297    }
298}
299
300// ── The terminal stage ──────────────────────────────────────────────────────
301
302/// The chain's split terminal. Runs the route closure over each record and
303/// aggregates the branches' lifecycle (relieve/flush/fatal). See the
304/// [module docs](self).
305pub struct SplitTerminal<SrcF: RecFamily, G> {
306    route: G,
307    branches: Vec<Box<dyn ErasedBranch>>,
308    unmatched: ErrorPolicy,
309    meter: OpMeterSlot,
310    fatal: FatalSlot,
311    component: Arc<str>,
312    _family: PhantomData<fn() -> SrcF>,
313}
314
315impl<SrcF: RecFamily, G> SplitTerminal<SrcF, G> {
316    pub(crate) fn new(
317        route: G,
318        branches: Vec<Box<dyn ErasedBranch>>,
319        unmatched: ErrorPolicy,
320        meter: OpMeterSlot,
321        component: Arc<str>,
322    ) -> Self {
323        SplitTerminal {
324            route,
325            branches,
326            unmatched,
327            meter,
328            fatal: FatalSlot(None),
329            component,
330            _family: PhantomData,
331        }
332    }
333}
334
335impl<SrcF: RecFamily, G> std::fmt::Debug for SplitTerminal<SrcF, G> {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        f.debug_struct("SplitTerminal")
338            .field("branches", &self.branches.len())
339            .field("unmatched", &self.unmatched)
340            .finish_non_exhaustive()
341    }
342}
343
344impl<'buf, SrcF, G> Collector<<SrcF as RecFamily>::Rec<'buf>> for SplitTerminal<SrcF, G>
345where
346    SrcF: RecFamily,
347    G: for<'b> FnMut(SrcF::Rec<'b>, &mut SplitEmitter<'_>),
348{
349    fn push(&mut self, rec: Record<SrcF::Rec<'buf>>) -> Flow {
350        self.meter.0.seen();
351        // A latched fatal short-circuits the rest of the batch, just like
352        // `SinkHandoff` — the chain drains it via `take_fatal`.
353        if self.fatal.0.is_some() {
354            return Flow::Continue;
355        }
356        let Record {
357            payload, meta, ack, ..
358        } = rec;
359        let mut em = SplitEmitter {
360            branches: &mut self.branches,
361            meta,
362            ack: &ack,
363            emitted: 0,
364            flow: Flow::Continue,
365        };
366        (self.route)(payload, &mut em);
367        let (emitted, flow) = (em.emitted, em.flow);
368        if emitted == 0 {
369            match self.unmatched {
370                // Drop-and-count: the record's ack share releases as success
371                // when `ack` drops here, exactly like a `filter` drop.
372                ErrorPolicy::Skip => self.meter.0.unrouted(),
373                // Stop the pipeline: the driver fails the batch's ack.
374                _ => {
375                    self.fatal.0 = Some(FatalError {
376                        component: self.component.to_string(),
377                        reason: "record matched no split branch".into(),
378                    });
379                }
380            }
381        } else {
382            self.meter.0.out_n(u64::from(emitted));
383        }
384        flow
385    }
386}
387
388impl<SrcF: RecFamily, G> StageLifecycle for SplitTerminal<SrcF, G> {
389    fn on_batch_end(&mut self, elapsed: Duration) {
390        self.meter.0.flush(elapsed);
391        for branch in &mut self.branches {
392            branch.on_batch_end(elapsed);
393        }
394    }
395
396    fn take_fatal(&mut self) -> Option<FatalError> {
397        if let Some(fatal) = self.fatal.0.take() {
398            return Some(fatal);
399        }
400        for branch in &mut self.branches {
401            if let Some(fatal) = branch.take_fatal() {
402                return Some(fatal);
403            }
404        }
405        None
406    }
407
408    fn relieve(&mut self) -> Flow {
409        // Relieve every branch (make progress on all), block if any is backed
410        // up — a branch that stays blocked keeps the chain from new payloads.
411        let mut flow = Flow::Continue;
412        for branch in &mut self.branches {
413            if branch.relieve() == Flow::Blocked {
414                flow = Flow::Blocked;
415            }
416        }
417        flow
418    }
419
420    fn flush_terminal(&mut self) -> Flow {
421        let mut flow = Flow::Continue;
422        for branch in &mut self.branches {
423            if branch.flush_terminal() == Flow::Blocked {
424                flow = Flow::Blocked;
425            }
426        }
427        flow
428    }
429}