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. A shard with parked chunks parks every chunk it seals
8//! after them, and parked chunks drain in seal order, so the chunks this
9//! stage seals for a shard reach its queue in seal order. Each pipeline
10//! thread runs its own stage over the shared queues, so chunks from
11//! different threads can reach a shard in any order.
12
13use super::Collector;
14use super::chain::{FatalSlot, OpMeterSlot, StageLifecycle};
15use crate::backpressure::InflightBudget;
16use crate::checkpoint::{AckSet, BatchId};
17use crate::deser::RecFamily;
18use crate::error::{ErrorClass, ErrorPolicy, FatalError, SinkError};
19use crate::record::{Flow, Record};
20use crate::sink::{
21    ChunkSendError, EncodedChunk, FrameCodec, RecordRouter, RowEncoder, ShardQueues,
22};
23use crate::telemetry::RateLimit;
24use bytes::{Bytes, BytesMut};
25use std::collections::VecDeque;
26use std::num::NonZeroU32;
27use std::sync::Arc;
28use std::time::{Duration, Instant};
29
30use crate::clock::{Clock, SystemClock};
31
32/// Tuning for the terminal stage's per-shard chunking.
33#[derive(Clone, Copy, Debug)]
34pub struct ChunkConfig {
35    /// Seal and send a chunk once its frame reaches this size. Small
36    /// enough to flow steadily, large enough to amortize queue traffic;
37    /// sink workers merge chunks into full-size batches, so this does
38    /// **not** bound insert sizes. A buffer below this size is not held
39    /// indefinitely: the controller flushes partial chunks on every commit
40    /// tick (`checkpoint.interval`), and the driver flushes them on an idle
41    /// lull, so while the pipeline is unblocked a partial buffer holds its
42    /// acknowledgments for at most ~one checkpoint interval. (A driver
43    /// wedged retrying a blocked batch defers the flush until the sink
44    /// drains, though nothing commits during that time anyway.)
45    pub target_bytes: usize,
46    /// Policy for record-level encoder failures. `Skip` drops the record
47    /// (metrics-counted); `Fail` stops the pipeline. An encoder error of
48    /// [`ErrorClass::Fatal`] stops the pipeline regardless of this policy;
49    /// fatal means the component is broken, not the record.
50    pub encode_policy: ErrorPolicy,
51}
52
53impl Default for ChunkConfig {
54    fn default() -> Self {
55        ChunkConfig {
56            target_bytes: 64 * 1024,
57            encode_policy: ErrorPolicy::Skip,
58        }
59    }
60}
61
62/// Per-shard accumulation state, including this shard's own encoder
63/// instance. A columnar encoder (ClickHouse Native) buffers its rows
64/// internally until the chunk is finalized, so each shard must own its
65/// encoder. A single shared encoder would interleave rows from different
66/// shards into one block. Row formats clone a trivial unit and are
67/// unaffected.
68#[derive(Debug)]
69struct ShardBuf<E> {
70    encoder: E,
71    buf: BytesMut,
72    rows: u32,
73    acks: AckSet,
74    last_batch: Option<BatchId>,
75    /// When the first record of the open chunk arrived.
76    first_ingest: Option<Instant>,
77    /// Smallest event time seen in the open chunk (ms since epoch).
78    oldest_event_ms: i64,
79    /// `rows` when a codec error left `buf` holding a block the encoder
80    /// already finished. A seal skips `finish_chunk` while `rows` still
81    /// equals it, so no row has been encoded since. A seal with no rows
82    /// returns before it reaches the codec, so the count is never zero.
83    finished_rows: Option<NonZeroU32>,
84    /// How many of this shard's chunks wait in [`SinkHandoff::parked`].
85    parked: u32,
86}
87
88/// This stage's own [`FrameCodec`] instance. The wrapper gives
89/// [`SinkHandoff`] a `Debug` impl, which the trait does not require.
90struct CodecSlot(Box<dyn FrameCodec>);
91
92impl std::fmt::Debug for CodecSlot {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_str("FrameCodec")
95    }
96}
97
98static ENCODE_SKIP_WARN: RateLimit = RateLimit::new(5, Duration::from_secs(10));
99
100/// The chain's terminal stage. Owns one accumulation buffer per shard,
101/// seals [`EncodedChunk`]s at [`ChunkConfig::target_bytes`], and hands
102/// them to the sink workers through the bounded [`ShardQueues`], with a
103/// `try_send` that never blocks the pipeline thread.
104#[derive(Debug)]
105pub struct SinkHandoff<F: RecFamily, E, R> {
106    router: R,
107    queues: ShardQueues,
108    budget: Arc<InflightBudget>,
109    cfg: ChunkConfig,
110    shards: Vec<ShardBuf<E>>,
111    /// Sealed chunks waiting for a queue slot, in seal order.
112    parked: VecDeque<(usize, EncodedChunk)>,
113    /// The sink's frame codec, made from `queues` for this stage alone.
114    codec: Option<CodecSlot>,
115    pub(crate) meter: OpMeterSlot,
116    pub(crate) fatal: FatalSlot,
117    component: Arc<str>,
118    _family: std::marker::PhantomData<fn() -> F>,
119}
120
121impl<F: RecFamily, E, R> SinkHandoff<F, E, R>
122where
123    E: RowEncoder<F> + Clone,
124{
125    pub(crate) fn new(
126        encoder: E,
127        router: R,
128        queues: ShardQueues,
129        budget: Arc<InflightBudget>,
130        cfg: ChunkConfig,
131        meter: OpMeterSlot,
132        component: Arc<str>,
133    ) -> Self {
134        // Defense-in-depth on the cold per-thread build path. The
135        // field-named rejection lives at load time (`chunk.target_bytes` in
136        // the YAML/`SinkOptions` paths, via
137        // `config::chunk::ChunkSection::resolve` and
138        // `Pipeline::add_sink_with`); this catches a direct construction bug,
139        // where a zero would break `BytesMut::with_capacity` and the seal
140        // check below.
141        assert!(cfg.target_bytes > 0, "chunk target must be non-zero");
142        let shards = (0..queues.num_shards())
143            .map(|_| ShardBuf {
144                encoder: encoder.clone(),
145                // Pre-size so the first chunk fills a target-sized buffer
146                // instead of regrowing (realloc + memcpy) from zero.
147                buf: BytesMut::with_capacity(cfg.target_bytes),
148                rows: 0,
149                acks: AckSet::new(),
150                last_batch: None,
151                first_ingest: None,
152                oldest_event_ms: i64::MAX,
153                finished_rows: None,
154                parked: 0,
155            })
156            .collect();
157        let codec = queues.new_frame_codec().map(CodecSlot);
158        SinkHandoff {
159            router,
160            codec,
161            queues,
162            budget,
163            cfg,
164            shards,
165            parked: VecDeque::new(),
166            meter,
167            fatal: FatalSlot(None),
168            component,
169            _family: std::marker::PhantomData,
170        }
171    }
172
173    /// Seal shard `idx`'s buffer into a chunk and try to send it. With a
174    /// frame codec, the codec's output becomes the frame. The in-flight
175    /// budget grows at seal time by the length before the codec, because a
176    /// parked chunk is in-flight memory too; the sink worker releases the
177    /// same amount after the batch is written or abandoned.
178    fn seal_and_send(&mut self, idx: usize) {
179        let shard = &mut self.shards[idx];
180        if shard.rows == 0 {
181            return;
182        }
183        // Columnar encoders buffer their rows internally; flush the pending
184        // block into `buf` as one complete frame before sealing (a no-op for
185        // row formats, which already wrote every row in `encode`). A finalize
186        // failure means a broken encoder, not a bad record: record it fatal
187        // and ship nothing. The shard keeps its rows and acks, which ship if a
188        // later finalize succeeds and fail on teardown otherwise. A flush seals
189        // every shard after this one, so the latch keeps the first reason.
190        let before = shard.buf.len();
191        if shard.finished_rows != NonZeroU32::new(shard.rows)
192            && let Err(e) = shard.encoder.finish_chunk(&mut shard.buf)
193        {
194            // The encoder may have written a partial block; roll it back to
195            // the rows `encode` wrote, so the frame stays well-formed.
196            shard.buf.truncate(before);
197            self.meter.0.fatal_error();
198            self.fatal.0.get_or_insert_with(|| FatalError {
199                component: self.component.to_string(),
200                reason: e.to_string(),
201            });
202            return;
203        }
204        let (frame, raw_len) = match &mut self.codec {
205            None => {
206                let frame = shard.buf.split().freeze();
207                // `split()` left the emptied buffer sharing the frozen frame's
208                // allocation, so growing it in place is impossible while that
209                // frame is in flight. Reserve a fresh target-sized allocation
210                // now (one alloc per seal) so the next chunk accumulates
211                // without repeatedly reallocating and copying the partial
212                // frame.
213                shard.buf.reserve(self.cfg.target_bytes);
214                let raw_len = frame.len();
215                (frame, raw_len)
216            }
217            Some(CodecSlot(codec)) => match codec.encode_frame(&shard.buf) {
218                Ok(coded) => {
219                    let frame = Bytes::copy_from_slice(coded);
220                    let raw_len = shard.buf.len();
221                    // The frame is a copy, so the buffer is reused in place.
222                    shard.buf.clear();
223                    shard.finished_rows = None;
224                    // One oversized row would otherwise keep its buffer for
225                    // the life of the chain.
226                    if shard.buf.capacity() > 2 * self.cfg.target_bytes {
227                        shard.buf = BytesMut::with_capacity(self.cfg.target_bytes);
228                    }
229                    (frame, raw_len)
230                }
231                Err(e) => {
232                    // The finished block stays in the buffer, so the rows and
233                    // acks stay with the shard as on a failed finalize.
234                    shard.finished_rows = NonZeroU32::new(shard.rows);
235                    self.meter.0.fatal_error();
236                    self.fatal.0.get_or_insert_with(|| FatalError {
237                        component: self.component.to_string(),
238                        reason: e.to_string(),
239                    });
240                    return;
241                }
242            },
243        };
244        self.budget.add(raw_len);
245        let chunk = EncodedChunk {
246            frame,
247            raw_len,
248            rows: shard.rows,
249            acks: std::mem::take(&mut shard.acks),
250            // Real time: the sink worker ages this stamp on the system clock.
251            oldest_ingest: shard
252                .first_ingest
253                .take()
254                .unwrap_or_else(|| SystemClock.now()),
255            oldest_event_ms: shard.oldest_event_ms,
256        };
257        shard.rows = 0;
258        shard.last_batch = None;
259        shard.oldest_event_ms = i64::MAX;
260        // A free slot must not let this chunk overtake one parked before it.
261        if shard.parked > 0 {
262            shard.parked += 1;
263            self.parked.push_back((idx, chunk));
264            return;
265        }
266        match self.queues.try_send(idx, chunk) {
267            Ok(()) => {}
268            Err(ChunkSendError(chunk)) => {
269                shard.parked += 1;
270                self.parked.push_back((idx, chunk));
271            }
272        }
273    }
274
275    /// Drain parked chunks in seal order. Returns whether all cleared.
276    fn drain_parked(&mut self) -> bool {
277        while let Some((idx, chunk)) = self.parked.pop_front() {
278            match self.queues.try_send(idx, chunk) {
279                Ok(()) => self.shards[idx].parked -= 1,
280                Err(ChunkSendError(chunk)) => {
281                    self.parked.push_front((idx, chunk));
282                    return false;
283                }
284            }
285        }
286        true
287    }
288
289    /// Capacity of shard `idx`'s accumulation buffer.
290    #[cfg(test)]
291    pub(crate) fn buffer_capacity(&self, idx: usize) -> usize {
292        self.shards[idx].buf.capacity()
293    }
294}
295
296/// Teardown safety: un-sent output (parked chunks after a drain deadline,
297/// partial shard buffers) holds its acknowledgments in fail-on-drop
298/// [`AckSet`]s, so tearing the handoff down stalls those watermarks and the
299/// records replay after restart. This `Drop` reconciles only the in-flight
300/// byte budget for parked chunks (their `raw_len` was added at seal time).
301impl<F: RecFamily, E, R> Drop for SinkHandoff<F, E, R> {
302    fn drop(&mut self) {
303        for (_, chunk) in self.parked.drain(..) {
304            self.budget.sub(chunk.raw_len);
305        }
306    }
307}
308
309impl<'buf, F, E, R> Collector<<F as RecFamily>::Rec<'buf>> for SinkHandoff<F, E, R>
310where
311    F: RecFamily,
312    E: RowEncoder<F> + Clone,
313    R: RecordRouter<F>,
314{
315    fn push(&mut self, rec: Record<F::Rec<'buf>>) -> Flow {
316        self.meter.0.seen();
317        // Records after a fatal are dropped until `take_fatal` drains the
318        // slot at the end of the payload. Nothing past this return reaches
319        // a shard buffer.
320        if self.fatal.0.is_some() {
321            return Flow::Continue;
322        }
323        let idx = self.router.route_record(&rec, self.shards.len());
324        let shard = &mut self.shards[idx];
325        let before = shard.buf.len();
326        match shard.encoder.encode(&rec, &mut shard.buf) {
327            Ok(()) => {
328                shard.rows += 1;
329                self.meter.0.out();
330                // Real time, as at the seal above.
331                shard.first_ingest.get_or_insert_with(|| SystemClock.now());
332                shard.oldest_event_ms = shard.oldest_event_ms.min(rec.meta.event_time_ms);
333                let bid = rec.ack.batch_id();
334                if shard.last_batch != Some(bid) {
335                    shard.acks.push(rec.ack.clone());
336                    shard.last_batch = Some(bid);
337                }
338                // Columnar encoders hold the block in `encoder`, not `buf`;
339                // count what they've buffered so a block still seals at the
340                // target size. `buffered_bytes()` is 0 for row formats, so
341                // this reduces to the plain `buf.len()` check for them.
342                if shard.buf.len() + shard.encoder.buffered_bytes() >= self.cfg.target_bytes {
343                    self.seal_and_send(idx);
344                }
345                Flow::Continue
346            }
347            Err(e) => {
348                // The encoder may have written a partial row; roll it back
349                // so the frame stays well-formed.
350                shard.buf.truncate(before);
351                // A Fatal-class error means the component is broken, not
352                // the record ("processing must stop"), so it overrides the
353                // record-level policy. Skipping it once per record would
354                // silently drop everything.
355                let fatal_class = matches!(
356                    e,
357                    SinkError::Client {
358                        class: ErrorClass::Fatal,
359                        ..
360                    }
361                );
362                match self.cfg.encode_policy {
363                    ErrorPolicy::Skip if !fatal_class => {
364                        self.meter.0.skipped();
365                        self.meter.0.record_error();
366                        crate::rate_limited_warn!(
367                            ENCODE_SKIP_WARN,
368                            component = &*self.component,
369                            error = %e,
370                            "record skipped by sink encoder error policy"
371                        );
372                    }
373                    _ => {
374                        self.meter.0.fatal_error();
375                        self.fatal.0.get_or_insert_with(|| FatalError {
376                            component: self.component.to_string(),
377                            reason: e.to_string(),
378                        });
379                    }
380                }
381                Flow::Continue
382            }
383        }
384    }
385}
386
387impl<F: RecFamily, E, R> StageLifecycle for SinkHandoff<F, E, R>
388where
389    E: RowEncoder<F> + Clone,
390{
391    fn on_batch_end(&mut self, elapsed: Duration) {
392        self.meter.0.flush(elapsed);
393    }
394
395    fn take_fatal(&mut self) -> Option<FatalError> {
396        self.fatal.0.take()
397    }
398
399    fn relieve(&mut self) -> Flow {
400        if self.parked.is_empty() || self.drain_parked() {
401            Flow::Continue
402        } else {
403            Flow::Blocked
404        }
405    }
406
407    fn flush_terminal(&mut self) -> Flow {
408        for idx in 0..self.shards.len() {
409            self.seal_and_send(idx);
410        }
411        if self.drain_parked() {
412            Flow::Continue
413        } else {
414            Flow::Blocked
415        }
416    }
417}