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