Skip to main content

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}