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