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