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}