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}