Skip to main content

spate_core/ops/
handoff.rs

1//! The terminal stage: route, encode, chunk, and hand off to the sink
2//! queues, all on the pipeline thread.
3//!
4//! Pressure discipline (matches [`StageLifecycle`]'s contract): `push`
5//! never rejects a record. When a sealed chunk cannot be sent it is parked
6//! and pressure is reported through `relieve()`, which the chain checks
7//! between payloads. Parked chunks always drain before newer ones, so
8//! per-shard order is preserved.
9
10use super::Collector;
11use super::chain::{FatalSlot, OpMeterSlot, StageLifecycle};
12use crate::backpressure::InflightBudget;
13use crate::checkpoint::{AckSet, BatchId};
14use crate::deser::RecFamily;
15use crate::error::{ErrorClass, ErrorPolicy, FatalError, SinkError};
16use crate::record::{Flow, Record};
17use crate::sink::{ChunkSendError, EncodedChunk, RecordRouter, RowEncoder, ShardQueues};
18use crate::telemetry::RateLimit;
19use bytes::BytesMut;
20use std::collections::VecDeque;
21use std::sync::Arc;
22use std::time::{Duration, Instant};
23
24/// Tuning for the terminal stage's per-shard chunking.
25#[derive(Clone, Copy, Debug)]
26pub struct ChunkConfig {
27    /// Seal and send a chunk once its frame reaches this size. Small
28    /// enough to flow steadily, large enough to amortize queue traffic;
29    /// sink workers merge chunks into full-size batches, so this does
30    /// **not** bound insert sizes. A buffer below this size is not held
31    /// indefinitely: the controller flushes partial chunks on every commit
32    /// tick (`checkpoint.interval`), and the driver flushes them on an idle
33    /// lull, so while the pipeline is unblocked a partial buffer holds its
34    /// acknowledgments for at most ~one checkpoint interval. (A driver
35    /// wedged retrying a blocked batch defers the flush until the sink
36    /// drains, though nothing commits during that time anyway.)
37    pub target_bytes: usize,
38    /// Policy for record-level encoder failures. `Skip` drops the record
39    /// (metrics-counted); `Fail` stops the pipeline. An encoder error of
40    /// [`ErrorClass::Fatal`] stops the pipeline regardless of this policy;
41    /// fatal means the component is broken, not the record.
42    pub encode_policy: ErrorPolicy,
43}
44
45impl Default for ChunkConfig {
46    fn default() -> Self {
47        ChunkConfig {
48            target_bytes: 64 * 1024,
49            encode_policy: ErrorPolicy::Skip,
50        }
51    }
52}
53
54/// Per-shard accumulation state, including this shard's own encoder
55/// instance. A columnar encoder (ClickHouse Native) buffers its rows
56/// internally until the chunk is finalized, so each shard must own its
57/// encoder. A single shared encoder would interleave rows from different
58/// shards into one block. Row formats clone a trivial unit and are
59/// unaffected.
60#[derive(Debug)]
61struct ShardBuf<E> {
62    encoder: E,
63    buf: BytesMut,
64    rows: u32,
65    acks: AckSet,
66    last_batch: Option<BatchId>,
67    /// When the first record of the open chunk arrived.
68    first_ingest: Option<Instant>,
69    /// Smallest event time seen in the open chunk (ms since epoch).
70    oldest_event_ms: i64,
71}
72
73static ENCODE_SKIP_WARN: RateLimit = RateLimit::new(5, Duration::from_secs(10));
74
75/// The chain's terminal stage. Owns one accumulation buffer per shard,
76/// seals [`EncodedChunk`]s at [`ChunkConfig::target_bytes`], and hands
77/// them to the sink workers through the bounded [`ShardQueues`], with a
78/// `try_send` that never blocks the pipeline thread.
79#[derive(Debug)]
80pub struct SinkHandoff<F: RecFamily, E, R> {
81    router: R,
82    queues: ShardQueues,
83    budget: Arc<InflightBudget>,
84    cfg: ChunkConfig,
85    shards: Vec<ShardBuf<E>>,
86    /// Sealed chunks that could not be sent, in seal order.
87    parked: VecDeque<(usize, EncodedChunk)>,
88    pub(crate) meter: OpMeterSlot,
89    pub(crate) fatal: FatalSlot,
90    component: Arc<str>,
91    _family: std::marker::PhantomData<fn() -> F>,
92}
93
94impl<F: RecFamily, E, R> SinkHandoff<F, E, R>
95where
96    E: RowEncoder<F> + Clone,
97{
98    pub(crate) fn new(
99        encoder: E,
100        router: R,
101        queues: ShardQueues,
102        budget: Arc<InflightBudget>,
103        cfg: ChunkConfig,
104        meter: OpMeterSlot,
105        component: Arc<str>,
106    ) -> Self {
107        // Defense-in-depth on the cold per-thread build path. The
108        // field-named rejection lives at load time (`chunk.target_bytes` in
109        // the YAML/`SinkOptions` paths, via
110        // `config::chunk::ChunkSection::resolve` and
111        // `Pipeline::add_sink_with`); this catches a direct construction bug,
112        // where a zero would break `BytesMut::with_capacity` and the seal
113        // check below.
114        assert!(cfg.target_bytes > 0, "chunk target must be non-zero");
115        let shards = (0..queues.num_shards())
116            .map(|_| ShardBuf {
117                encoder: encoder.clone(),
118                // Pre-size so the first chunk fills a target-sized buffer
119                // instead of regrowing (realloc + memcpy) from zero.
120                buf: BytesMut::with_capacity(cfg.target_bytes),
121                rows: 0,
122                acks: AckSet::new(),
123                last_batch: None,
124                first_ingest: None,
125                oldest_event_ms: i64::MAX,
126            })
127            .collect();
128        SinkHandoff {
129            router,
130            queues,
131            budget,
132            cfg,
133            shards,
134            parked: VecDeque::new(),
135            meter,
136            fatal: FatalSlot(None),
137            component,
138            _family: std::marker::PhantomData,
139        }
140    }
141
142    /// Seal shard `idx`'s buffer into a chunk and try to send it. The
143    /// in-flight budget grows at seal time, because a parked chunk is
144    /// in-flight memory too; the sink worker releases the bytes after the
145    /// batch is written or abandoned.
146    fn seal_and_send(&mut self, idx: usize) {
147        let shard = &mut self.shards[idx];
148        if shard.rows == 0 {
149            return;
150        }
151        // Columnar encoders buffer their rows internally; flush the pending
152        // block into `buf` as one complete frame before sealing (a no-op for
153        // row formats, which already wrote every row in `encode`). A finalize
154        // failure means a broken encoder, not a bad record: record it fatal
155        // and ship nothing. The shard's captured acks fail on teardown, so
156        // the rows replay.
157        if let Err(e) = shard.encoder.finish_chunk(&mut shard.buf) {
158            self.fatal.0 = Some(FatalError {
159                component: self.component.to_string(),
160                reason: e.to_string(),
161            });
162            return;
163        }
164        let frame = shard.buf.split().freeze();
165        // `split()` left the emptied buffer sharing the frozen frame's
166        // allocation, so growing it in place is impossible while that frame is
167        // in flight. Reserve a fresh target-sized allocation now (one alloc
168        // per seal) so the next chunk accumulates without repeatedly
169        // reallocating and copying the partial frame.
170        shard.buf.reserve(self.cfg.target_bytes);
171        self.budget.add(frame.len());
172        let chunk = EncodedChunk {
173            frame,
174            rows: shard.rows,
175            acks: std::mem::take(&mut shard.acks),
176            oldest_ingest: shard.first_ingest.take().unwrap_or_else(Instant::now),
177            oldest_event_ms: shard.oldest_event_ms,
178        };
179        shard.rows = 0;
180        shard.last_batch = None;
181        shard.oldest_event_ms = i64::MAX;
182        match self.queues.try_send(idx, chunk) {
183            Ok(()) => {}
184            Err(ChunkSendError(chunk)) => self.parked.push_back((idx, chunk)),
185        }
186    }
187
188    /// Drain parked chunks in seal order. Returns whether all cleared.
189    fn drain_parked(&mut self) -> bool {
190        while let Some((idx, chunk)) = self.parked.pop_front() {
191            match self.queues.try_send(idx, chunk) {
192                Ok(()) => {}
193                Err(ChunkSendError(chunk)) => {
194                    self.parked.push_front((idx, chunk));
195                    return false;
196                }
197            }
198        }
199        true
200    }
201}
202
203/// Teardown safety: un-sent output (parked chunks after a drain deadline,
204/// partial shard buffers) holds its acknowledgments in fail-on-drop
205/// [`AckSet`]s, so tearing the handoff down stalls those watermarks and the
206/// records replay after restart. This `Drop` reconciles only the in-flight
207/// byte budget for parked chunks (their bytes were added at seal time).
208impl<F: RecFamily, E, R> Drop for SinkHandoff<F, E, R> {
209    fn drop(&mut self) {
210        for (_, chunk) in self.parked.drain(..) {
211            self.budget.sub(chunk.frame.len());
212        }
213    }
214}
215
216impl<'buf, F, E, R> Collector<<F as RecFamily>::Rec<'buf>> for SinkHandoff<F, E, R>
217where
218    F: RecFamily,
219    E: RowEncoder<F> + Clone,
220    R: RecordRouter<F>,
221{
222    fn push(&mut self, rec: Record<F::Rec<'buf>>) -> Flow {
223        self.meter.0.seen();
224        if self.fatal.0.is_some() {
225            return Flow::Continue;
226        }
227        let idx = self.router.route_record(&rec, self.shards.len());
228        let shard = &mut self.shards[idx];
229        let before = shard.buf.len();
230        match shard.encoder.encode(&rec, &mut shard.buf) {
231            Ok(()) => {
232                shard.rows += 1;
233                self.meter.0.out();
234                shard.first_ingest.get_or_insert_with(Instant::now);
235                shard.oldest_event_ms = shard.oldest_event_ms.min(rec.meta.event_time_ms);
236                let bid = rec.ack.batch_id();
237                if shard.last_batch != Some(bid) {
238                    shard.acks.push(rec.ack.clone());
239                    shard.last_batch = Some(bid);
240                }
241                // Columnar encoders hold the block in `encoder`, not `buf`;
242                // count what they've buffered so a block still seals at the
243                // target size. `buffered_bytes()` is 0 for row formats, so
244                // this reduces to the plain `buf.len()` check for them.
245                if shard.buf.len() + shard.encoder.buffered_bytes() >= self.cfg.target_bytes {
246                    self.seal_and_send(idx);
247                }
248                Flow::Continue
249            }
250            Err(e) => {
251                // The encoder may have written a partial row; roll it back
252                // so the frame stays well-formed.
253                shard.buf.truncate(before);
254                // A Fatal-class error means the component is broken, not
255                // the record ("processing must stop"), so it overrides the
256                // record-level policy. Skipping it once per record would
257                // silently drop everything.
258                let fatal_class = matches!(
259                    e,
260                    SinkError::Client {
261                        class: ErrorClass::Fatal,
262                        ..
263                    }
264                );
265                match self.cfg.encode_policy {
266                    ErrorPolicy::Skip if !fatal_class => {
267                        self.meter.0.skipped();
268                        self.meter.0.record_error();
269                        crate::rate_limited_warn!(
270                            ENCODE_SKIP_WARN,
271                            component = &*self.component,
272                            error = %e,
273                            "record skipped by sink encoder error policy"
274                        );
275                    }
276                    _ => {
277                        self.fatal.0 = Some(FatalError {
278                            component: self.component.to_string(),
279                            reason: e.to_string(),
280                        });
281                    }
282                }
283                Flow::Continue
284            }
285        }
286    }
287}
288
289impl<F: RecFamily, E, R> StageLifecycle for SinkHandoff<F, E, R>
290where
291    E: RowEncoder<F> + Clone,
292{
293    fn on_batch_end(&mut self, elapsed: Duration) {
294        self.meter.0.flush(elapsed);
295    }
296
297    fn take_fatal(&mut self) -> Option<FatalError> {
298        self.fatal.0.take()
299    }
300
301    fn relieve(&mut self) -> Flow {
302        if self.parked.is_empty() || self.drain_parked() {
303            Flow::Continue
304        } else {
305            Flow::Blocked
306        }
307    }
308
309    fn flush_terminal(&mut self) -> Flow {
310        for idx in 0..self.shards.len() {
311            self.seal_and_send(idx);
312        }
313        if self.drain_parked() {
314            Flow::Continue
315        } else {
316            Flow::Blocked
317        }
318    }
319}