Skip to main content

spate_core/sink/
mod.rs

1//! Sink abstraction: pipeline threads encode, shard workers batch and
2//! write.
3//!
4//! The division of labour (ADR-0006):
5//!
6//! - **Pipeline threads** route each record to a shard, then run the
7//!   sink's [`RowEncoder`] inside the chain's terminal stage, accumulating
8//!   encoded rows into small [`EncodedChunk`] frames per shard and
9//!   `try_send`ing them into bounded per-shard queues. The send never
10//!   blocks; a full queue surfaces as backpressure. Routing has two tiers
11//!   over one seam: meta-only [`ShardRouter`] (the default
12//!   [`KeyHashRouter`] uses the key hash, else a stable partition hash) or
13//!   record-aware [`RecordRouter`] for payload-derived shard affinity.
14//! - **Shard workers** (tokio tasks) merge chunks from all pipeline
15//!   threads into full-size batches, seal on `max_rows` / `max_bytes` /
16//!   `linger`, and dispatch up to `max_inflight` concurrent
17//!   [`ShardWriter::write_batch`] calls rotating across healthy replicas.
18//!   Merging at the worker keeps batches large regardless of the pipeline
19//!   thread count.
20//!
21//! A connector implements [`RowEncoder`] (CPU half) and [`ShardWriter`]
22//! (I/O half), and may ship a [`RecordRouter`] when the target's sharding
23//! is payload-derived; the framework owns everything between them.
24
25mod breaker;
26mod bundle;
27mod config;
28mod failures;
29mod pool;
30#[cfg(test)]
31mod pool_tests;
32mod queue;
33mod retry;
34mod worker;
35
36pub use bundle::{SinkBundle, SinkParts};
37pub use config::{
38    BatchConfig, BreakerConfig, BreakerConfigError, InflightConfig, RetryConfig, RetryConfigError,
39    SinkPoolConfig,
40};
41pub use failures::SinkFailures;
42pub use pool::{DrainReport, SinkPool};
43pub use queue::{ChunkSendError, ShardQueues, shard_queues};
44
45/// Boxed sink drain hook: budget in, report out. Produced by sink
46/// assemblies (wrapping [`SinkPool::drain`]), consumed once at shutdown by
47/// the pipeline runtime.
48pub type SinkDrainFn = Box<
49    dyn FnOnce(std::time::Duration) -> std::pin::Pin<Box<dyn Future<Output = DrainReport> + Send>>
50        + Send,
51>;
52
53/// Boxed, repeatable sink connectivity probe (readiness). The runtime
54/// probes at startup and then periodically, driving the sinks-connected
55/// half of `/readyz`.
56pub type SinkProbeFn = Box<
57    dyn Fn() -> std::pin::Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send>> + Send + Sync,
58>;
59
60/// Build a [`SinkProbeFn`] that probes every replica of every shard in
61/// `shard_endpoints` (indexed `[shard][replica]`) via
62/// [`ShardWriter::probe`]. This is the readiness loop
63/// [`SinkParts::with_probe`](crate::sink::SinkParts::with_probe) expects.
64/// Back `writer` with an independent probe client set, never the insert
65/// clients (see [`SinkParts::probe`](crate::sink::SinkParts)).
66pub fn endpoint_probe<W>(
67    writer: W,
68    shard_endpoints: std::sync::Arc<Vec<Vec<W::Endpoint>>>,
69) -> SinkProbeFn
70where
71    W: ShardWriter + Clone,
72{
73    Box::new(move || {
74        let writer = writer.clone();
75        let shard_endpoints = std::sync::Arc::clone(&shard_endpoints);
76        Box::pin(async move {
77            for shard in shard_endpoints.iter() {
78                for endpoint in shard {
79                    writer.probe(endpoint).await?;
80                }
81            }
82            Ok(())
83        })
84    })
85}
86
87use crate::checkpoint::AckSet;
88use crate::deser::RecFamily;
89use crate::error::SinkError;
90use crate::metrics::Meter;
91use crate::record::{Record, RecordMeta};
92use bytes::{Bytes, BytesMut};
93use std::time::Instant;
94
95/// A small frame of encoded rows produced on a pipeline thread, the unit
96/// shipped over the per-shard queues. Wire frames are concatenable, so
97/// workers accumulate chunks without re-encoding. Either the format is
98/// headerless (RowBinary rows appended back-to-back), or each frame is one
99/// complete, self-describing block (ClickHouse Native) and a concatenation
100/// of complete blocks is itself a legal insert stream.
101///
102/// Teardown safety: `acks` is an [`AckSet`]. Dropping a chunk anywhere
103/// (a closed queue, an aborted worker, a parked chunk at teardown) fails
104/// its batches so their offsets never commit; only a completed durable
105/// write delivers them.
106#[derive(Debug)]
107pub struct EncodedChunk {
108    /// Encoded rows in the sink's wire format.
109    pub frame: Bytes,
110    /// Number of rows in `frame`.
111    pub rows: u32,
112    /// Acknowledgment handles of the source batches represented in
113    /// `frame`. Consecutive records usually share a batch, so this stays
114    /// short (the encoder dedupes consecutive identical handles).
115    pub acks: AckSet,
116    /// When the oldest record in `frame` entered the terminal stage
117    /// (ingest-basis end-to-end latency).
118    pub oldest_ingest: Instant,
119    /// Smallest record event time in `frame`, milliseconds since the epoch
120    /// (event-basis end-to-end latency).
121    pub oldest_event_ms: i64,
122}
123
124/// The CPU half of a sink connector: encodes one record into the sink's
125/// wire format. Runs on pinned pipeline threads inside the chain's
126/// terminal stage; must not perform I/O. Family-generic and dyn-compatible,
127/// like [`Deserializer`](crate::deser::Deserializer).
128pub trait RowEncoder<F: RecFamily>: Send {
129    /// Append `rec`'s encoding to `buf`. Errors are record-level and
130    /// subject to the sink stage's `ErrorPolicy`. Errors of
131    /// [`ErrorClass::Fatal`](crate::error::ErrorClass::Fatal) stop the
132    /// pipeline regardless of policy; fatal means the encoder itself is
133    /// broken (e.g. the row type cannot match the target schema), so every
134    /// subsequent record would fail identically.
135    fn encode<'buf>(
136        &mut self,
137        rec: &Record<F::Rec<'buf>>,
138        buf: &mut BytesMut,
139    ) -> Result<(), SinkError>;
140
141    /// Bytes the encoder is holding internally that have **not** yet been
142    /// flushed to a frame. Row formats append directly in
143    /// [`encode`](Self::encode) and buffer nothing, so the default is `0`.
144    /// Columnar formats (which must transpose a whole block before any bytes
145    /// exist) return the approximate size of the block under assembly; the
146    /// terminal stage adds this to the shard buffer length when deciding
147    /// whether to seal a chunk, so a columnar block still respects
148    /// [`ChunkConfig::target_bytes`](crate::ops::ChunkConfig).
149    fn buffered_bytes(&self) -> usize {
150        0
151    }
152
153    /// Finalize the pending chunk: flush any internally-buffered rows into
154    /// `buf` as exactly **one** complete, self-describing wire frame, leaving
155    /// the encoder empty and ready for the next chunk. Row formats already
156    /// wrote every row in [`encode`](Self::encode), so the default is a
157    /// no-op. The terminal stage calls this immediately before it seals each
158    /// [`EncodedChunk`] (in steady state, on data lulls, and at drain), so a
159    /// columnar encoder's buffered rows are not silently dropped.
160    ///
161    /// An `Err` is fatal (a broken encoder, not a bad record). The stage
162    /// discards whatever the failed call wrote and ships no partial frame.
163    /// The stop it triggers is asynchronous, so the chunk stays open and a
164    /// later seal may call `finish_chunk` again for it, by which point the
165    /// chunk may hold rows the failed call never saw. Return `Ok` only from a
166    /// call whose frame carries every row the chunk holds. The stage discards
167    /// what the call wrote and not the row count, so an encoder that had
168    /// already moved its rows into `buf` when it failed re-encodes them, and
169    /// keeps failing until it can. Rows no call finalizes have their acknowledgments fail on
170    /// teardown, so the data replays.
171    ///
172    /// Because a Native block concatenates with the blocks around it, each
173    /// `finish_chunk` frame is independently valid, so workers still
174    /// accumulate frames without re-encoding.
175    fn finish_chunk(&mut self, buf: &mut BytesMut) -> Result<(), SinkError> {
176        let _ = buf;
177        Ok(())
178    }
179}
180
181/// A batch sealed by a shard worker, ready to write. Frames concatenate to
182/// the full wire payload (a stream of one or more self-describing blocks for
183/// block formats like ClickHouse Native).
184#[derive(Debug)]
185pub struct SealedBatch {
186    /// Encoded frames, in order.
187    pub frames: Vec<Bytes>,
188    /// Total rows across `frames`.
189    pub rows: u64,
190    /// Total bytes across `frames`.
191    pub bytes: u64,
192    /// Deterministic-within-a-session batch identity. Retries of the same
193    /// sealed batch (including on other replicas) reuse the same token,
194    /// so sinks with server-side deduplication windows treat them as
195    /// idempotent. Crash replay produces different tokens (documented
196    /// at-least-once semantics).
197    pub dedup_token: String,
198}
199
200/// The I/O half of a sink connector: writes one sealed batch to one
201/// replica endpoint. Returning `Ok` is the durable-ack point; only then
202/// may the framework resolve the batch's acknowledgments.
203pub trait ShardWriter: Send + Sync + 'static {
204    /// A connected replica endpoint (e.g. one HTTP client per replica).
205    type Endpoint: Send + Sync + 'static;
206
207    /// Receive a [`Meter`] scoped `spate_<component_type>_sink_*` for the sink's
208    /// own metric families, pre-labeled with the standard
209    /// `pipeline`/`component`/`component_type`. Called once by the builder
210    /// before the writer is shared across shard workers; resolve handles here
211    /// and store them (they are `Arc`-backed, so `write_batch`'s `&self` can
212    /// touch them). `None` when the sink's `component_type` cannot scope a
213    /// family: the default `"custom"` (reserved for pipeline-author metrics) or
214    /// a reserved root opts out silently, and a malformed value is logged and
215    /// also yields `None`. Declare a distinct `component_type` via
216    /// [`SinkParts::with_component_type`](crate::sink::SinkParts::with_component_type)
217    /// to receive a scope. Defaults to ignoring it.
218    fn attach_metrics(&mut self, meter: Option<Meter>) {
219        let _ = meter;
220    }
221
222    /// Write `batch` to `endpoint` durably.
223    ///
224    /// **Bound it.** The framework guarantees that shutdown terminates, not
225    /// that this call does. At the drain deadline the write task is aborted,
226    /// which only lands if this future is at an await point. A client with no
227    /// request timeout turns every shutdown into a wait for the deadline, and
228    /// one that blocks its thread between awaits cannot be aborted at all.
229    /// Give the underlying client a request timeout and let the framework's
230    /// retry policy handle the failure.
231    fn write_batch(
232        &self,
233        endpoint: &Self::Endpoint,
234        batch: &SealedBatch,
235    ) -> impl Future<Output = Result<(), SinkError>> + Send;
236
237    /// Connectivity probe for readiness. Defaults to healthy.
238    fn probe(
239        &self,
240        endpoint: &Self::Endpoint,
241    ) -> impl Future<Output = Result<(), SinkError>> + Send {
242        let _ = endpoint;
243        async { Ok(()) }
244    }
245}
246
247/// Routes records to shards on metadata alone, the **meta-only tier** of
248/// sink routing. Pure and cheap; called per record on pipeline threads.
249///
250/// Every `ShardRouter` is also a [`RecordRouter`] for every record family
251/// through a blanket bridge, so meta-only routers plug into the same
252/// builder seam unchanged. Implement [`RecordRouter`] directly instead
253/// when routing needs the payload.
254pub trait ShardRouter: Send + Sync {
255    /// The shard index in `0..num_shards` for a record.
256    fn route(&self, meta: &RecordMeta, num_shards: usize) -> usize;
257}
258
259/// Default router: key hash modulo shards, falling back to the source
260/// partition for keyless records (keeps a partition's keyless records
261/// together and the distribution stable).
262#[derive(Clone, Copy, Debug, Default)]
263pub struct KeyHashRouter;
264
265impl ShardRouter for KeyHashRouter {
266    #[inline]
267    fn route(&self, meta: &RecordMeta, num_shards: usize) -> usize {
268        debug_assert!(num_shards > 0);
269        let h = meta
270            .key_hash
271            .unwrap_or_else(|| u64::from(meta.partition.0).wrapping_mul(0x9E37_79B9_7F4A_7C15));
272        (h % num_shards as u64) as usize
273    }
274}
275
276/// Routes records to shards with access to the full record, the
277/// **record-aware tier** of sink routing. Pure and cheap; called once per
278/// record on pinned pipeline threads, strictly before encoding. It must
279/// not perform I/O, block, or allocate per call. Family-generic and
280/// dyn-compatible, like [`RowEncoder`].
281///
282/// The two tiers share one seam:
283///
284/// - **Meta-only** ([`ShardRouter`]): routes on [`RecordMeta`] alone (key
285///   hash, source partition). The default [`KeyHashRouter`] lives here.
286///   Every `ShardRouter` is automatically a `RecordRouter` for every
287///   family through a blanket bridge, so meta-only routers plug into the
288///   same builder seam unchanged.
289/// - **Record-aware** (this trait): routes on the payload itself.
290///   Required when shard affinity derives from a field of the terminal
291///   record type (e.g. matching a sink cluster's own sharding expression),
292///   and the only way to route `flat_map` children independently, since
293///   children inherit their parent's [`RecordMeta`] and a meta-only router
294///   colocates them.
295///
296/// The router sees the record exactly as the [`RowEncoder`] will (after
297/// every transform), so a routing key must survive to the terminal record
298/// type. A router may hold state (a weights table, an atomic counter);
299/// `&self` plus interior mutability covers stateful strategies.
300///
301/// A router must also be **total**: return a shard index for every record
302/// and never panic. Routing has no per-record error policy; a record
303/// either has a well-defined shard or the router picks a deterministic
304/// fallback. Unlike an encoder error, which honors the sink
305/// stage's Skip/Fail policy, a router panic fails the in-flight batch and
306/// stops the pipeline; restart then replays the same record, so a
307/// payload-dependent panic is a deterministic crash loop until a code fix
308/// ships.
309///
310/// # Examples
311///
312/// A record-aware router over an owned family:
313///
314/// ```
315/// use spate_core::deser::Owned;
316/// use spate_core::record::Record;
317/// use spate_core::sink::RecordRouter;
318///
319/// struct ByLen;
320/// impl RecordRouter<Owned<Vec<u8>>> for ByLen {
321///     fn route_record<'buf>(&self, rec: &Record<Vec<u8>>, num_shards: usize) -> usize {
322///         rec.payload.len() % num_shards
323///     }
324/// }
325/// ```
326///
327/// Implement **either** this trait **or** [`ShardRouter`], never both;
328/// the bridge makes implementing both a coherence overlap:
329///
330/// ```compile_fail,E0119
331/// use spate_core::deser::Owned;
332/// use spate_core::record::{Record, RecordMeta};
333/// use spate_core::sink::{RecordRouter, ShardRouter};
334///
335/// struct Both;
336/// impl ShardRouter for Both {
337///     fn route(&self, _: &RecordMeta, _: usize) -> usize { 0 }
338/// }
339/// impl RecordRouter<Owned<Vec<u8>>> for Both {
340///     fn route_record<'buf>(&self, _: &Record<Vec<u8>>, _: usize) -> usize { 0 }
341/// }
342/// ```
343pub trait RecordRouter<F: RecFamily>: Send + Sync {
344    /// The shard index in `0..num_shards` for `rec`. Must be
345    /// `< num_shards`; the terminal stage indexes its shard buffers
346    /// directly with the result.
347    fn route_record<'buf>(&self, rec: &Record<F::Rec<'buf>>, num_shards: usize) -> usize;
348}
349
350/// Bridge: every meta-only [`ShardRouter`] routes any record family by
351/// ignoring the payload and delegating to [`ShardRouter::route`] on the
352/// record's metadata.
353impl<F: RecFamily, R: ShardRouter> RecordRouter<F> for R {
354    #[inline]
355    fn route_record<'buf>(&self, rec: &Record<F::Rec<'buf>>, num_shards: usize) -> usize {
356        self.route(&rec.meta, num_shards)
357    }
358}
359
360#[cfg(all(test, not(loom)))]
361mod tests {
362    use super::*;
363    use crate::checkpoint::AckRef;
364    use crate::deser::Owned;
365    use crate::record::PartitionId;
366
367    fn meta(key_hash: Option<u64>, partition: u32) -> RecordMeta {
368        RecordMeta {
369            partition: PartitionId(partition),
370            offset: 0,
371            event_time_ms: 0,
372            key_hash,
373        }
374    }
375
376    #[test]
377    fn key_hash_router_uses_key_then_partition() {
378        let r = KeyHashRouter;
379        assert_eq!(r.route(&meta(Some(10), 0), 4), (10 % 4) as usize);
380        // Keyless: stable per partition, and different partitions spread.
381        let a = r.route(&meta(None, 0), 4);
382        let b = r.route(&meta(None, 0), 4);
383        assert_eq!(a, b);
384        let spread: std::collections::HashSet<_> =
385            (0..16).map(|p| r.route(&meta(None, p), 4)).collect();
386        assert!(spread.len() > 1, "keyless records must not all colocate");
387    }
388
389    #[test]
390    fn shard_router_bridges_to_record_router_ignoring_payload() {
391        let (ack, _rx) = AckRef::test_pair();
392        for key_hash in [Some(10), Some(u64::MAX), None] {
393            let rec = Record {
394                payload: vec![1u8, 2, 3],
395                meta: meta(key_hash, 3),
396                ack: ack.clone(),
397            };
398            for n in [1usize, 2, 4, 7] {
399                assert_eq!(
400                    RecordRouter::<Owned<Vec<u8>>>::route_record(&KeyHashRouter, &rec, n),
401                    ShardRouter::route(&KeyHashRouter, &rec.meta, n),
402                    "the bridge must delegate to the meta-only route"
403                );
404            }
405        }
406    }
407
408    #[test]
409    fn record_router_is_dyn_compatible_for_a_concrete_family() {
410        let _router: &dyn RecordRouter<Owned<Vec<u8>>> = &KeyHashRouter;
411    }
412}