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    /// acknowledgements for at most ~one checkpoint interval. (A driver
35    /// wedged retrying a blocked batch defers the flush until the sink
36    /// drains — but 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`] — 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 friendly,
108        // field-named rejection now lives at load time (`chunk.target_bytes` in
109        // the YAML/`SinkOptions` paths — `config::chunk::ChunkSection::resolve`
110        // and `Pipeline::add_sink_with`); this only catches a direct
111        // construction bug, where a zero would break `BytesMut::with_capacity`
112        // and the seal check below.
113        assert!(cfg.target_bytes > 0, "chunk target must be non-zero");
114        let shards = (0..queues.num_shards())
115            .map(|_| ShardBuf {
116                // Each shard clones the template encoder: a columnar encoder
117                // carries per-block state that must not be shared across
118                // shards, and a row encoder clones a trivial unit.
119                encoder: encoder.clone(),
120                // Pre-size so the first chunk fills a target-sized buffer
121                // instead of regrowing (realloc + memcpy) from zero.
122                buf: BytesMut::with_capacity(cfg.target_bytes),
123                rows: 0,
124                acks: AckSet::new(),
125                last_batch: None,
126                first_ingest: None,
127                oldest_event_ms: i64::MAX,
128            })
129            .collect();
130        SinkHandoff {
131            router,
132            queues,
133            budget,
134            cfg,
135            shards,
136            parked: VecDeque::new(),
137            meter,
138            fatal: FatalSlot(None),
139            component,
140            _family: std::marker::PhantomData,
141        }
142    }
143
144    /// Seal shard `idx`'s buffer into a chunk and try to send it. The
145    /// in-flight budget grows at seal time — a parked chunk is in-flight
146    /// memory too; the sink worker releases the bytes after the batch is
147    /// written or abandoned.
148    fn seal_and_send(&mut self, idx: usize) {
149        let shard = &mut self.shards[idx];
150        if shard.rows == 0 {
151            return;
152        }
153        // Columnar encoders buffer their rows internally; flush the pending
154        // block into `buf` as one complete frame before sealing (a no-op for
155        // row formats, which already wrote every row in `encode`). A finalize
156        // failure means a broken encoder, not a bad record: record it fatal
157        // and ship nothing — the shard's captured acks fail on teardown, so
158        // the rows replay.
159        if let Err(e) = shard.encoder.finish_chunk(&mut shard.buf) {
160            self.fatal.0 = Some(FatalError {
161                component: self.component.to_string(),
162                reason: e.to_string(),
163            });
164            return;
165        }
166        let frame = shard.buf.split().freeze();
167        // `split()` left the emptied buffer sharing the frozen frame's
168        // allocation, so growing it in place is impossible while that frame is
169        // in flight. Reserve a fresh target-sized allocation now (one alloc
170        // per seal) so the next chunk accumulates without repeatedly
171        // reallocating and copying the partial frame.
172        shard.buf.reserve(self.cfg.target_bytes);
173        self.budget.add(frame.len());
174        let chunk = EncodedChunk {
175            frame,
176            rows: shard.rows,
177            acks: std::mem::take(&mut shard.acks),
178            oldest_ingest: shard.first_ingest.take().unwrap_or_else(Instant::now),
179            oldest_event_ms: shard.oldest_event_ms,
180        };
181        shard.rows = 0;
182        shard.last_batch = None;
183        shard.oldest_event_ms = i64::MAX;
184        match self.queues.try_send(idx, chunk) {
185            Ok(()) => {}
186            Err(ChunkSendError(chunk)) => self.parked.push_back((idx, chunk)),
187        }
188    }
189
190    /// Drain parked chunks in seal order. Returns whether all cleared.
191    fn drain_parked(&mut self) -> bool {
192        while let Some((idx, chunk)) = self.parked.pop_front() {
193            match self.queues.try_send(idx, chunk) {
194                Ok(()) => {}
195                Err(ChunkSendError(chunk)) => {
196                    self.parked.push_front((idx, chunk));
197                    return false;
198                }
199            }
200        }
201        true
202    }
203}
204
205/// Teardown safety: un-sent output (parked chunks after a drain deadline,
206/// partial shard buffers) holds its acknowledgements in fail-on-drop
207/// [`AckSet`]s, so tearing the handoff down stalls those watermarks and the
208/// records replay after restart — at-least-once over completeness, always.
209/// This `Drop` only reconciles the in-flight byte budget for parked chunks
210/// (their bytes were added at seal time).
211impl<F: RecFamily, E, R> Drop for SinkHandoff<F, E, R> {
212    fn drop(&mut self) {
213        for (_, chunk) in self.parked.drain(..) {
214            self.budget.sub(chunk.frame.len());
215        }
216    }
217}
218
219impl<'buf, F, E, R> Collector<<F as RecFamily>::Rec<'buf>> for SinkHandoff<F, E, R>
220where
221    F: RecFamily,
222    E: RowEncoder<F> + Clone,
223    R: RecordRouter<F>,
224{
225    fn push(&mut self, rec: Record<F::Rec<'buf>>) -> Flow {
226        self.meter.0.seen();
227        if self.fatal.0.is_some() {
228            return Flow::Continue;
229        }
230        let idx = self.router.route_record(&rec, self.shards.len());
231        let shard = &mut self.shards[idx];
232        let before = shard.buf.len();
233        match shard.encoder.encode(&rec, &mut shard.buf) {
234            Ok(()) => {
235                shard.rows += 1;
236                self.meter.0.out();
237                shard.first_ingest.get_or_insert_with(Instant::now);
238                shard.oldest_event_ms = shard.oldest_event_ms.min(rec.meta.event_time_ms);
239                let bid = rec.ack.batch_id();
240                if shard.last_batch != Some(bid) {
241                    shard.acks.push(rec.ack.clone());
242                    shard.last_batch = Some(bid);
243                }
244                // Columnar encoders hold the block in `encoder`, not `buf`;
245                // count what they've buffered so a block still seals at the
246                // target size. `buffered_bytes()` is 0 for row formats, so
247                // this reduces to the plain `buf.len()` check for them.
248                if shard.buf.len() + shard.encoder.buffered_bytes() >= self.cfg.target_bytes {
249                    self.seal_and_send(idx);
250                }
251                Flow::Continue
252            }
253            Err(e) => {
254                // The encoder may have written a partial row; roll it back
255                // so the frame stays well-formed.
256                shard.buf.truncate(before);
257                // A Fatal-class error means the component is broken, not
258                // the record ("processing must stop"): it overrides the
259                // record-level policy — skipping it once per record would
260                // silently drop everything.
261                let fatal_class = matches!(
262                    e,
263                    SinkError::Client {
264                        class: ErrorClass::Fatal,
265                        ..
266                    }
267                );
268                match self.cfg.encode_policy {
269                    ErrorPolicy::Skip if !fatal_class => {
270                        self.meter.0.skipped();
271                        self.meter.0.record_error();
272                        crate::rate_limited_warn!(
273                            ENCODE_SKIP_WARN,
274                            component = &*self.component,
275                            error = %e,
276                            "record skipped by sink encoder error policy"
277                        );
278                    }
279                    _ => {
280                        self.fatal.0 = Some(FatalError {
281                            component: self.component.to_string(),
282                            reason: e.to_string(),
283                        });
284                    }
285                }
286                Flow::Continue
287            }
288        }
289    }
290}
291
292impl<F: RecFamily, E, R> StageLifecycle for SinkHandoff<F, E, R>
293where
294    E: RowEncoder<F> + Clone,
295{
296    fn on_batch_end(&mut self, elapsed: Duration) {
297        self.meter.0.flush(elapsed);
298    }
299
300    fn take_fatal(&mut self) -> Option<FatalError> {
301        self.fatal.0.take()
302    }
303
304    fn relieve(&mut self) -> Flow {
305        if self.parked.is_empty() || self.drain_parked() {
306            Flow::Continue
307        } else {
308            Flow::Blocked
309        }
310    }
311
312    fn flush_terminal(&mut self) -> Flow {
313        for idx in 0..self.shards.len() {
314            self.seal_and_send(idx);
315        }
316        if self.drain_parked() {
317            Flow::Continue
318        } else {
319            Flow::Blocked
320        }
321    }
322}