etl_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, RowEncoder, ShardQueues, ShardRouter};
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.
31 pub target_bytes: usize,
32 /// Policy for record-level encoder failures. `Skip` drops the record
33 /// (metrics-counted); `Fail` stops the pipeline. An encoder error of
34 /// [`ErrorClass::Fatal`] stops the pipeline regardless of this policy
35 /// — fatal means the component is broken, not the record.
36 pub encode_policy: ErrorPolicy,
37}
38
39impl Default for ChunkConfig {
40 fn default() -> Self {
41 ChunkConfig {
42 target_bytes: 64 * 1024,
43 encode_policy: ErrorPolicy::Skip,
44 }
45 }
46}
47
48/// Per-shard accumulation state, including this shard's own encoder
49/// instance. A columnar encoder (ClickHouse Native) buffers its rows
50/// internally until the chunk is finalized, so each shard must own its
51/// encoder — a single shared encoder would interleave rows from different
52/// shards into one block. Row formats clone a trivial unit and are
53/// unaffected.
54#[derive(Debug)]
55struct ShardBuf<E> {
56 encoder: E,
57 buf: BytesMut,
58 rows: u32,
59 acks: AckSet,
60 last_batch: Option<BatchId>,
61 /// When the first record of the open chunk arrived.
62 first_ingest: Option<Instant>,
63 /// Smallest event time seen in the open chunk (ms since epoch).
64 oldest_event_ms: i64,
65}
66
67static ENCODE_SKIP_WARN: RateLimit = RateLimit::new(5, Duration::from_secs(10));
68
69/// The chain's terminal stage. Owns one accumulation buffer per shard,
70/// seals [`EncodedChunk`]s at [`ChunkConfig::target_bytes`], and hands
71/// them to the sink workers through the bounded [`ShardQueues`] — a
72/// `try_send` that never blocks the pipeline thread.
73#[derive(Debug)]
74pub struct SinkHandoff<F: RecFamily, E, R> {
75 router: R,
76 queues: ShardQueues,
77 budget: Arc<InflightBudget>,
78 cfg: ChunkConfig,
79 shards: Vec<ShardBuf<E>>,
80 /// Sealed chunks that could not be sent, in seal order.
81 parked: VecDeque<(usize, EncodedChunk)>,
82 pub(crate) meter: OpMeterSlot,
83 pub(crate) fatal: FatalSlot,
84 component: Arc<str>,
85 _family: std::marker::PhantomData<fn() -> F>,
86}
87
88impl<F: RecFamily, E, R> SinkHandoff<F, E, R>
89where
90 E: RowEncoder<F> + Clone,
91{
92 pub(crate) fn new(
93 encoder: E,
94 router: R,
95 queues: ShardQueues,
96 budget: Arc<InflightBudget>,
97 cfg: ChunkConfig,
98 meter: OpMeterSlot,
99 component: Arc<str>,
100 ) -> Self {
101 assert!(cfg.target_bytes > 0, "chunk target must be non-zero");
102 let shards = (0..queues.num_shards())
103 .map(|_| ShardBuf {
104 // Each shard clones the template encoder: a columnar encoder
105 // carries per-block state that must not be shared across
106 // shards, and a row encoder clones a trivial unit.
107 encoder: encoder.clone(),
108 // Pre-size so the first chunk fills a target-sized buffer
109 // instead of regrowing (realloc + memcpy) from zero.
110 buf: BytesMut::with_capacity(cfg.target_bytes),
111 rows: 0,
112 acks: AckSet::new(),
113 last_batch: None,
114 first_ingest: None,
115 oldest_event_ms: i64::MAX,
116 })
117 .collect();
118 SinkHandoff {
119 router,
120 queues,
121 budget,
122 cfg,
123 shards,
124 parked: VecDeque::new(),
125 meter,
126 fatal: FatalSlot(None),
127 component,
128 _family: std::marker::PhantomData,
129 }
130 }
131
132 /// Seal shard `idx`'s buffer into a chunk and try to send it. The
133 /// in-flight budget grows at seal time — a parked chunk is in-flight
134 /// memory too; the sink worker releases the bytes after the batch is
135 /// written or abandoned.
136 fn seal_and_send(&mut self, idx: usize) {
137 let shard = &mut self.shards[idx];
138 if shard.rows == 0 {
139 return;
140 }
141 // Columnar encoders buffer their rows internally; flush the pending
142 // block into `buf` as one complete frame before sealing (a no-op for
143 // row formats, which already wrote every row in `encode`). A finalize
144 // failure means a broken encoder, not a bad record: record it fatal
145 // and ship nothing — the shard's captured acks fail on teardown, so
146 // the rows replay.
147 if let Err(e) = shard.encoder.finish_chunk(&mut shard.buf) {
148 self.fatal.0 = Some(FatalError {
149 component: self.component.to_string(),
150 reason: e.to_string(),
151 });
152 return;
153 }
154 let frame = shard.buf.split().freeze();
155 // `split()` left the emptied buffer sharing the frozen frame's
156 // allocation, so growing it in place is impossible while that frame is
157 // in flight. Reserve a fresh target-sized allocation now (one alloc
158 // per seal) so the next chunk accumulates without repeatedly
159 // reallocating and copying the partial frame.
160 shard.buf.reserve(self.cfg.target_bytes);
161 self.budget.add(frame.len());
162 let chunk = EncodedChunk {
163 frame,
164 rows: shard.rows,
165 acks: std::mem::take(&mut shard.acks),
166 oldest_ingest: shard.first_ingest.take().unwrap_or_else(Instant::now),
167 oldest_event_ms: shard.oldest_event_ms,
168 };
169 shard.rows = 0;
170 shard.last_batch = None;
171 shard.oldest_event_ms = i64::MAX;
172 match self.queues.try_send(idx, chunk) {
173 Ok(()) => {}
174 Err(ChunkSendError(chunk)) => self.parked.push_back((idx, chunk)),
175 }
176 }
177
178 /// Drain parked chunks in seal order. Returns whether all cleared.
179 fn drain_parked(&mut self) -> bool {
180 while let Some((idx, chunk)) = self.parked.pop_front() {
181 match self.queues.try_send(idx, chunk) {
182 Ok(()) => {}
183 Err(ChunkSendError(chunk)) => {
184 self.parked.push_front((idx, chunk));
185 return false;
186 }
187 }
188 }
189 true
190 }
191}
192
193/// Teardown safety: un-sent output (parked chunks after a drain deadline,
194/// partial shard buffers) holds its acknowledgements in fail-on-drop
195/// [`AckSet`]s, so tearing the handoff down stalls those watermarks and the
196/// records replay after restart — at-least-once over completeness, always.
197/// This `Drop` only reconciles the in-flight byte budget for parked chunks
198/// (their bytes were added at seal time).
199impl<F: RecFamily, E, R> Drop for SinkHandoff<F, E, R> {
200 fn drop(&mut self) {
201 for (_, chunk) in self.parked.drain(..) {
202 self.budget.sub(chunk.frame.len());
203 }
204 }
205}
206
207impl<'buf, F, E, R> Collector<<F as RecFamily>::Rec<'buf>> for SinkHandoff<F, E, R>
208where
209 F: RecFamily,
210 E: RowEncoder<F> + Clone,
211 R: ShardRouter,
212{
213 fn push(&mut self, rec: Record<F::Rec<'buf>>) -> Flow {
214 self.meter.0.seen();
215 if self.fatal.0.is_some() {
216 return Flow::Continue;
217 }
218 let idx = self.router.route(&rec.meta, self.shards.len());
219 let shard = &mut self.shards[idx];
220 let before = shard.buf.len();
221 match shard.encoder.encode(&rec, &mut shard.buf) {
222 Ok(()) => {
223 shard.rows += 1;
224 self.meter.0.out();
225 shard.first_ingest.get_or_insert_with(Instant::now);
226 shard.oldest_event_ms = shard.oldest_event_ms.min(rec.meta.event_time_ms);
227 let bid = rec.ack.batch_id();
228 if shard.last_batch != Some(bid) {
229 shard.acks.push(rec.ack.clone());
230 shard.last_batch = Some(bid);
231 }
232 // Columnar encoders hold the block in `encoder`, not `buf`;
233 // count what they've buffered so a block still seals at the
234 // target size. `buffered_bytes()` is 0 for row formats, so
235 // this reduces to the plain `buf.len()` check for them.
236 if shard.buf.len() + shard.encoder.buffered_bytes() >= self.cfg.target_bytes {
237 self.seal_and_send(idx);
238 }
239 Flow::Continue
240 }
241 Err(e) => {
242 // The encoder may have written a partial row; roll it back
243 // so the frame stays well-formed.
244 shard.buf.truncate(before);
245 // A Fatal-class error means the component is broken, not
246 // the record ("processing must stop"): it overrides the
247 // record-level policy — skipping it once per record would
248 // silently drop everything.
249 let fatal_class = matches!(
250 e,
251 SinkError::Client {
252 class: ErrorClass::Fatal,
253 ..
254 }
255 );
256 match self.cfg.encode_policy {
257 ErrorPolicy::Skip if !fatal_class => {
258 self.meter.0.skipped();
259 self.meter.0.record_error();
260 crate::rate_limited_warn!(
261 ENCODE_SKIP_WARN,
262 component = &*self.component,
263 error = %e,
264 "record skipped by sink encoder error policy"
265 );
266 }
267 _ => {
268 self.fatal.0 = Some(FatalError {
269 component: self.component.to_string(),
270 reason: e.to_string(),
271 });
272 }
273 }
274 Flow::Continue
275 }
276 }
277 }
278}
279
280impl<F: RecFamily, E, R> StageLifecycle for SinkHandoff<F, E, R>
281where
282 E: RowEncoder<F> + Clone,
283{
284 fn on_batch_end(&mut self, elapsed: Duration) {
285 self.meter.0.flush(elapsed);
286 }
287
288 fn take_fatal(&mut self) -> Option<FatalError> {
289 self.fatal.0.take()
290 }
291
292 fn relieve(&mut self) -> Flow {
293 if self.parked.is_empty() || self.drain_parked() {
294 Flow::Continue
295 } else {
296 Flow::Blocked
297 }
298 }
299
300 fn flush_terminal(&mut self) -> Flow {
301 for idx in 0..self.shards.len() {
302 self.seal_and_send(idx);
303 }
304 if self.drain_parked() {
305 Flow::Continue
306 } else {
307 Flow::Blocked
308 }
309 }
310}