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