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}