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 keeps its rows and acks, which ship if a
156        // later finalize succeeds and fail on teardown otherwise. A flush seals
157        // every shard after this one, so the latch keeps the first reason.
158        let before = shard.buf.len();
159        if let Err(e) = shard.encoder.finish_chunk(&mut shard.buf) {
160            // The encoder may have written a partial block; roll it back to
161            // the rows `encode` wrote, so the frame stays well-formed.
162            shard.buf.truncate(before);
163            self.meter.0.fatal_error();
164            self.fatal.0.get_or_insert_with(|| FatalError {
165                component: self.component.to_string(),
166                reason: e.to_string(),
167            });
168            return;
169        }
170        let frame = shard.buf.split().freeze();
171        // `split()` left the emptied buffer sharing the frozen frame's
172        // allocation, so growing it in place is impossible while that frame is
173        // in flight. Reserve a fresh target-sized allocation now (one alloc
174        // per seal) so the next chunk accumulates without repeatedly
175        // reallocating and copying the partial frame.
176        shard.buf.reserve(self.cfg.target_bytes);
177        self.budget.add(frame.len());
178        let chunk = EncodedChunk {
179            frame,
180            rows: shard.rows,
181            acks: std::mem::take(&mut shard.acks),
182            oldest_ingest: shard.first_ingest.take().unwrap_or_else(Instant::now),
183            oldest_event_ms: shard.oldest_event_ms,
184        };
185        shard.rows = 0;
186        shard.last_batch = None;
187        shard.oldest_event_ms = i64::MAX;
188        match self.queues.try_send(idx, chunk) {
189            Ok(()) => {}
190            Err(ChunkSendError(chunk)) => self.parked.push_back((idx, chunk)),
191        }
192    }
193
194    /// Drain parked chunks in seal order. Returns whether all cleared.
195    fn drain_parked(&mut self) -> bool {
196        while let Some((idx, chunk)) = self.parked.pop_front() {
197            match self.queues.try_send(idx, chunk) {
198                Ok(()) => {}
199                Err(ChunkSendError(chunk)) => {
200                    self.parked.push_front((idx, chunk));
201                    return false;
202                }
203            }
204        }
205        true
206    }
207}
208
209/// Teardown safety: un-sent output (parked chunks after a drain deadline,
210/// partial shard buffers) holds its acknowledgments in fail-on-drop
211/// [`AckSet`]s, so tearing the handoff down stalls those watermarks and the
212/// records replay after restart. This `Drop` reconciles only the in-flight
213/// byte budget for parked chunks (their bytes were added at seal time).
214impl<F: RecFamily, E, R> Drop for SinkHandoff<F, E, R> {
215    fn drop(&mut self) {
216        for (_, chunk) in self.parked.drain(..) {
217            self.budget.sub(chunk.frame.len());
218        }
219    }
220}
221
222impl<'buf, F, E, R> Collector<<F as RecFamily>::Rec<'buf>> for SinkHandoff<F, E, R>
223where
224    F: RecFamily,
225    E: RowEncoder<F> + Clone,
226    R: RecordRouter<F>,
227{
228    fn push(&mut self, rec: Record<F::Rec<'buf>>) -> Flow {
229        self.meter.0.seen();
230        // Records after a fatal are dropped until `take_fatal` drains the
231        // slot at the end of the payload. Nothing past this return reaches
232        // a shard buffer.
233        if self.fatal.0.is_some() {
234            return Flow::Continue;
235        }
236        let idx = self.router.route_record(&rec, self.shards.len());
237        let shard = &mut self.shards[idx];
238        let before = shard.buf.len();
239        match shard.encoder.encode(&rec, &mut shard.buf) {
240            Ok(()) => {
241                shard.rows += 1;
242                self.meter.0.out();
243                shard.first_ingest.get_or_insert_with(Instant::now);
244                shard.oldest_event_ms = shard.oldest_event_ms.min(rec.meta.event_time_ms);
245                let bid = rec.ack.batch_id();
246                if shard.last_batch != Some(bid) {
247                    shard.acks.push(rec.ack.clone());
248                    shard.last_batch = Some(bid);
249                }
250                // Columnar encoders hold the block in `encoder`, not `buf`;
251                // count what they've buffered so a block still seals at the
252                // target size. `buffered_bytes()` is 0 for row formats, so
253                // this reduces to the plain `buf.len()` check for them.
254                if shard.buf.len() + shard.encoder.buffered_bytes() >= self.cfg.target_bytes {
255                    self.seal_and_send(idx);
256                }
257                Flow::Continue
258            }
259            Err(e) => {
260                // The encoder may have written a partial row; roll it back
261                // so the frame stays well-formed.
262                shard.buf.truncate(before);
263                // A Fatal-class error means the component is broken, not
264                // the record ("processing must stop"), so it overrides the
265                // record-level policy. Skipping it once per record would
266                // silently drop everything.
267                let fatal_class = matches!(
268                    e,
269                    SinkError::Client {
270                        class: ErrorClass::Fatal,
271                        ..
272                    }
273                );
274                match self.cfg.encode_policy {
275                    ErrorPolicy::Skip if !fatal_class => {
276                        self.meter.0.skipped();
277                        self.meter.0.record_error();
278                        crate::rate_limited_warn!(
279                            ENCODE_SKIP_WARN,
280                            component = &*self.component,
281                            error = %e,
282                            "record skipped by sink encoder error policy"
283                        );
284                    }
285                    _ => {
286                        self.meter.0.fatal_error();
287                        self.fatal.0.get_or_insert_with(|| FatalError {
288                            component: self.component.to_string(),
289                            reason: e.to_string(),
290                        });
291                    }
292                }
293                Flow::Continue
294            }
295        }
296    }
297}
298
299impl<F: RecFamily, E, R> StageLifecycle for SinkHandoff<F, E, R>
300where
301    E: RowEncoder<F> + Clone,
302{
303    fn on_batch_end(&mut self, elapsed: Duration) {
304        self.meter.0.flush(elapsed);
305    }
306
307    fn take_fatal(&mut self) -> Option<FatalError> {
308        self.fatal.0.take()
309    }
310
311    fn relieve(&mut self) -> Flow {
312        if self.parked.is_empty() || self.drain_parked() {
313            Flow::Continue
314        } else {
315            Flow::Blocked
316        }
317    }
318
319    fn flush_terminal(&mut self) -> Flow {
320        for idx in 0..self.shards.len() {
321            self.seal_and_send(idx);
322        }
323        if self.drain_parked() {
324            Flow::Continue
325        } else {
326            Flow::Blocked
327        }
328    }
329}