Skip to main content

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}