etl_core/sink/mod.rs
1//! Sink abstraction: pipeline threads encode, shard workers batch and
2//! write.
3//!
4//! The division of labour (see `docs/DESIGN.md` § Sink):
5//!
6//! - **Pipeline threads** run the sink's [`RowEncoder`] inside the chain's
7//! terminal stage, accumulating encoded rows into small [`EncodedChunk`]
8//! frames per shard and `try_send`ing them into bounded per-shard queues
9//! (never blocking — a full queue surfaces as backpressure).
10//! - **Shard workers** (tokio tasks) merge chunks from all pipeline
11//! threads into full-size batches, seal on `max_rows` / `max_bytes` /
12//! `linger`, and dispatch up to `max_inflight` concurrent
13//! [`ShardWriter::write_batch`] calls rotating across healthy replicas.
14//! Merging at the worker keeps batches large regardless of the pipeline
15//! thread count.
16//!
17//! A connector implements [`RowEncoder`] (CPU half) and [`ShardWriter`]
18//! (I/O half); the framework owns everything between them.
19
20mod breaker;
21mod bundle;
22mod config;
23mod pool;
24#[cfg(test)]
25mod pool_tests;
26mod queue;
27mod retry;
28mod worker;
29
30pub use bundle::{SinkBundle, SinkParts};
31pub use config::{BatchConfig, BreakerConfig, InflightConfig, RetryConfig, SinkPoolConfig};
32pub use pool::{DrainReport, SinkPool};
33pub use queue::{ChunkSendError, ShardQueues, shard_queues};
34
35/// Boxed sink drain hook: budget in, report out. Produced by sink
36/// assemblies (wrapping [`SinkPool::drain`]), consumed once at shutdown by
37/// the pipeline runtime.
38pub type SinkDrainFn = Box<
39 dyn FnOnce(std::time::Duration) -> std::pin::Pin<Box<dyn Future<Output = DrainReport> + Send>>
40 + Send,
41>;
42
43/// Boxed, repeatable sink connectivity probe (readiness). The runtime
44/// probes at startup and then periodically, driving the sinks-connected
45/// half of `/readyz`.
46pub type SinkProbeFn = Box<
47 dyn Fn() -> std::pin::Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send>> + Send + Sync,
48>;
49
50/// Build a [`SinkProbeFn`] that probes every replica of every shard in
51/// `shard_endpoints` (indexed `[shard][replica]`) via
52/// [`ShardWriter::probe`] — the readiness loop
53/// [`SinkParts::with_probe`](crate::sink::SinkParts::with_probe) expects.
54/// Back `writer` with an independent probe client set, never the insert
55/// clients (see [`SinkParts::probe`](crate::sink::SinkParts)).
56pub fn endpoint_probe<W>(
57 writer: W,
58 shard_endpoints: std::sync::Arc<Vec<Vec<W::Endpoint>>>,
59) -> SinkProbeFn
60where
61 W: ShardWriter + Clone,
62{
63 Box::new(move || {
64 let writer = writer.clone();
65 let shard_endpoints = std::sync::Arc::clone(&shard_endpoints);
66 Box::pin(async move {
67 for shard in shard_endpoints.iter() {
68 for endpoint in shard {
69 writer.probe(endpoint).await?;
70 }
71 }
72 Ok(())
73 })
74 })
75}
76
77use crate::checkpoint::AckSet;
78use crate::deser::RecFamily;
79use crate::error::SinkError;
80use crate::record::{Record, RecordMeta};
81use bytes::{Bytes, BytesMut};
82use std::time::Instant;
83
84/// A small frame of encoded rows produced on a pipeline thread, the unit
85/// shipped over the per-shard queues. Wire frames are concatenable — either
86/// the format is headerless (RowBinary rows appended back-to-back) or each
87/// frame is one complete, self-describing block (ClickHouse Native), and a
88/// concatenation of complete blocks is itself a legal insert stream — so
89/// workers accumulate chunks without re-encoding.
90///
91/// Teardown safety: `acks` is an [`AckSet`] — dropping a chunk anywhere
92/// (a closed queue, an aborted worker, a parked chunk at teardown) fails
93/// its batches so their offsets never commit; only a completed durable
94/// write delivers them.
95#[derive(Debug)]
96pub struct EncodedChunk {
97 /// Encoded rows in the sink's wire format.
98 pub frame: Bytes,
99 /// Number of rows in `frame`.
100 pub rows: u32,
101 /// Acknowledgement handles of the source batches represented in
102 /// `frame`. Consecutive records usually share a batch, so this stays
103 /// short (the encoder dedupes consecutive identical handles).
104 pub acks: AckSet,
105 /// When the oldest record in `frame` entered the terminal stage
106 /// (ingest-basis end-to-end latency).
107 pub oldest_ingest: Instant,
108 /// Smallest record event time in `frame`, milliseconds since the epoch
109 /// (event-basis end-to-end latency).
110 pub oldest_event_ms: i64,
111}
112
113/// The CPU half of a sink connector: encodes one record into the sink's
114/// wire format. Runs on pinned pipeline threads inside the chain's
115/// terminal stage; must not perform I/O. Family-generic and dyn-compatible,
116/// like [`Deserializer`](crate::deser::Deserializer).
117pub trait RowEncoder<F: RecFamily>: Send {
118 /// Append `rec`'s encoding to `buf`. Errors are record-level and
119 /// subject to the sink stage's `ErrorPolicy` — except errors of
120 /// [`ErrorClass::Fatal`](crate::error::ErrorClass::Fatal), which stop
121 /// the pipeline regardless of policy (fatal means the encoder itself
122 /// is broken, e.g. the row type cannot match the target schema; every
123 /// subsequent record would fail identically).
124 fn encode<'buf>(
125 &mut self,
126 rec: &Record<F::Rec<'buf>>,
127 buf: &mut BytesMut,
128 ) -> Result<(), SinkError>;
129
130 /// Bytes the encoder is holding internally that have **not** yet been
131 /// flushed to a frame. Row formats append directly in
132 /// [`encode`](Self::encode) and buffer nothing, so the default is `0`.
133 /// Columnar formats (which must transpose a whole block before any bytes
134 /// exist) return the approximate size of the block under assembly; the
135 /// terminal stage adds this to the shard buffer length when deciding
136 /// whether to seal a chunk, so a columnar block still respects
137 /// [`ChunkConfig::target_bytes`](crate::ops::ChunkConfig).
138 fn buffered_bytes(&self) -> usize {
139 0
140 }
141
142 /// Finalize the pending chunk: flush any internally-buffered rows into
143 /// `buf` as exactly **one** complete, self-describing wire frame, leaving
144 /// the encoder empty and ready for the next chunk. Row formats already
145 /// wrote every row in [`encode`](Self::encode), so the default is a
146 /// no-op. The terminal stage calls this immediately before it seals each
147 /// [`EncodedChunk`] — in steady state, on data lulls, and at drain — so a
148 /// columnar encoder's buffered rows are never silently dropped.
149 ///
150 /// An `Err` is fatal (a broken encoder, not a bad record): the stage
151 /// ships no partial frame and the buffered rows' acknowledgements fail on
152 /// teardown, so the data replays. Because a Native block concatenates
153 /// with the blocks around it, each `finish_chunk` frame is independently
154 /// valid — workers still accumulate frames without re-encoding.
155 fn finish_chunk(&mut self, buf: &mut BytesMut) -> Result<(), SinkError> {
156 let _ = buf;
157 Ok(())
158 }
159}
160
161/// A batch sealed by a shard worker, ready to write. Frames concatenate to
162/// the full wire payload (a stream of one or more self-describing blocks for
163/// block formats like ClickHouse Native).
164#[derive(Debug)]
165pub struct SealedBatch {
166 /// Encoded frames, in order.
167 pub frames: Vec<Bytes>,
168 /// Total rows across `frames`.
169 pub rows: u64,
170 /// Total bytes across `frames`.
171 pub bytes: u64,
172 /// Deterministic-within-a-session batch identity. Retries of the same
173 /// sealed batch — including on other replicas — reuse the same token,
174 /// so sinks with server-side deduplication windows treat them as
175 /// idempotent. Crash replay produces different tokens (documented
176 /// at-least-once semantics).
177 pub dedup_token: String,
178}
179
180/// The I/O half of a sink connector: writes one sealed batch to one
181/// replica endpoint. Returning `Ok` is the durable-ack point — only then
182/// may the framework resolve the batch's acknowledgements.
183pub trait ShardWriter: Send + Sync + 'static {
184 /// A connected replica endpoint (e.g. one HTTP client per replica).
185 type Endpoint: Send + Sync + 'static;
186
187 /// Write `batch` to `endpoint` durably.
188 fn write_batch(
189 &self,
190 endpoint: &Self::Endpoint,
191 batch: &SealedBatch,
192 ) -> impl Future<Output = Result<(), SinkError>> + Send;
193
194 /// Connectivity probe for readiness. Defaults to healthy.
195 fn probe(
196 &self,
197 endpoint: &Self::Endpoint,
198 ) -> impl Future<Output = Result<(), SinkError>> + Send {
199 let _ = endpoint;
200 async { Ok(()) }
201 }
202}
203
204/// Routes records to shards. Pure and cheap — called per record on
205/// pipeline threads.
206pub trait ShardRouter: Send + Sync {
207 /// The shard index in `0..num_shards` for a record.
208 fn route(&self, meta: &RecordMeta, num_shards: usize) -> usize;
209}
210
211/// Default router: key hash modulo shards, falling back to the source
212/// partition for keyless records (keeps a partition's keyless records
213/// together and the distribution stable).
214#[derive(Clone, Copy, Debug, Default)]
215pub struct KeyHashRouter;
216
217impl ShardRouter for KeyHashRouter {
218 #[inline]
219 fn route(&self, meta: &RecordMeta, num_shards: usize) -> usize {
220 debug_assert!(num_shards > 0);
221 let h = meta
222 .key_hash
223 .unwrap_or_else(|| u64::from(meta.partition.0).wrapping_mul(0x9E37_79B9_7F4A_7C15));
224 (h % num_shards as u64) as usize
225 }
226}
227
228#[cfg(all(test, not(loom)))]
229mod tests {
230 use super::*;
231 use crate::record::PartitionId;
232
233 fn meta(key_hash: Option<u64>, partition: u32) -> RecordMeta {
234 RecordMeta {
235 partition: PartitionId(partition),
236 offset: 0,
237 event_time_ms: 0,
238 key_hash,
239 }
240 }
241
242 #[test]
243 fn key_hash_router_uses_key_then_partition() {
244 let r = KeyHashRouter;
245 assert_eq!(r.route(&meta(Some(10), 0), 4), (10 % 4) as usize);
246 // Keyless: stable per partition, and different partitions spread.
247 let a = r.route(&meta(None, 0), 4);
248 let b = r.route(&meta(None, 0), 4);
249 assert_eq!(a, b);
250 let spread: std::collections::HashSet<_> =
251 (0..16).map(|p| r.route(&meta(None, p), 4)).collect();
252 assert!(spread.len() > 1, "keyless records must not all colocate");
253 }
254}