Skip to main content

spate_clickhouse/
router.rs

1//! Distributed-parity shard routing: place each record on the same shard a
2//! ClickHouse `Distributed` table with sharding expression
3//! `xxHash64(<key column>)` would.
4//!
5//! Inserting directly into shard-local tables is this sink's whole design;
6//! [`DistributedRouter`] is what lets SELECTs through a `Distributed` table
7//! still prune shards (`optimize_skip_unused_shards=1`): it reproduces the
8//! engine's placement — `xxHash64(key) % sum(weights)`, remainder mapped to
9//! consecutive half-open weight intervals in **config order** (weights
10//! `[9, 10]` → shard 0 owns `[0, 9)`, shard 1 owns `[9, 19)`).
11//!
12//! **Ordering contract (documented, not verifiable by the framework):**
13//! `shards[i]` in the sink config must be the cluster's `shard_num = i + 1`
14//! — the same order as the `remote_servers` entry the Distributed DDL
15//! names. Weights must match the cluster's `<weight>`s. The opt-in
16//! `distributed_check` config block verifies counts, weights, and the
17//! sharding expression at startup; the ordering itself is on the operator.
18
19use spate_core::config::ConfigError;
20use spate_core::deser::RecFamily;
21use spate_core::record::Record;
22use spate_core::sink::RecordRouter;
23use std::fmt;
24use std::sync::Arc;
25use twox_hash::XxHash64;
26
27/// The sharding key extracted from one record. Borrows where possible;
28/// integer variants carry the value and are hashed from a stack array — no
29/// per-record allocation anywhere.
30///
31/// Integer widths matter: ClickHouse hashes a column at its **declared
32/// width** as little-endian bytes, so a `UInt32` column key must use
33/// [`ShardKey::U32`] — widening it to `U64` hashes 8 bytes instead of 4 and
34/// silently breaks parity. Parity also requires a non-`Nullable` key
35/// column: ClickHouse hashes the `Nullable` wrapper differently.
36#[derive(Clone, Copy, Debug)]
37#[non_exhaustive]
38pub enum ShardKey<'a> {
39    /// A `String` sharding column: hashed as its UTF-8 bytes.
40    Str(&'a str),
41    /// Raw bytes (`FixedString`, pre-encoded keys): hashed as-is.
42    Bytes(&'a [u8]),
43    /// A `UInt64` column: hashed as 8 little-endian bytes. For `Int64`
44    /// pass `v as u64` — bit-identical little-endian bytes.
45    U64(u64),
46    /// A `UInt32`/`DateTime` column: hashed as 4 little-endian bytes. For
47    /// `Int32` pass `v as u32`.
48    U32(u32),
49}
50
51/// Pure per-record key extractor for family `F`. A plain `fn` pointer: the
52/// higher-ranked signature over the `Rec<'buf>` GAT defeats closure
53/// inference, so write a named fn — fn items and non-capturing closures
54/// coerce to this type:
55///
56/// ```ignore
57/// fn sensor_key<'a>(row: &'a SensorEvent<'_>) -> ShardKey<'a> {
58///     ShardKey::Str(row.sensor)
59/// }
60/// ```
61///
62/// The extractor must be **total**: return a key for every record the
63/// terminal stage can see, and never panic. Routing has no Skip/Fail
64/// error policy (unlike encoding) — a panicking extractor fails the
65/// in-flight batch and stops the pipeline, and because restart replays
66/// the same record, a payload-dependent panic (say, slicing an empty
67/// field) is a deterministic crash loop until a code fix ships. For a
68/// malformed or absent key, return a deterministic fallback such as
69/// `ShardKey::Str("")` instead.
70pub type KeyExtractor<F> = for<'a, 'buf> fn(&'a <F as RecFamily>::Rec<'buf>) -> ShardKey<'a>;
71
72/// Routes records to shards exactly as a ClickHouse `Distributed` table
73/// with sharding expression `xxHash64(<key column>)` would (see the
74/// [module docs](self) for the algorithm and the ordering contract).
75///
76/// Construct via [`ClickHouseSink::router`](crate::ClickHouseSink::router)
77/// so weights come from the validated config and cannot disagree with the
78/// endpoint topology; [`DistributedRouter::new`] is the programmatic
79/// escape hatch. Cloning is free (a fn pointer and a shared weights
80/// table) — the terminal stage clones one router per pipeline thread.
81pub struct DistributedRouter<F: RecFamily> {
82    extract: KeyExtractor<F>,
83    /// Exclusive prefix-sum interval ends; `ends[last]` = total weight.
84    ends: Arc<[u64]>,
85    /// All-weights-1 fast path (`hash % N`) — the default config.
86    uniform: bool,
87}
88
89impl<F: RecFamily> DistributedRouter<F> {
90    /// Programmatic constructor: weights in shard-config order, each
91    /// `>= 1`. Prefer [`ClickHouseSink::router`](crate::ClickHouseSink::router),
92    /// which sources weights from the validated YAML.
93    pub fn new(extract: KeyExtractor<F>, weights: &[u32]) -> Result<Self, ConfigError> {
94        if weights.is_empty() {
95            return Err(ConfigError::Validation(
96                "DistributedRouter requires at least one shard weight".into(),
97            ));
98        }
99        if let Some(i) = weights.iter().position(|&w| w == 0) {
100            return Err(ConfigError::Validation(format!(
101                "DistributedRouter shard {i} has weight 0; ClickHouse weights are \
102                 interval widths and must be at least 1"
103            )));
104        }
105        let mut ends = Vec::with_capacity(weights.len());
106        let mut acc = 0u64;
107        for &w in weights {
108            acc += u64::from(w);
109            ends.push(acc);
110        }
111        Ok(DistributedRouter {
112            extract,
113            ends: ends.into(),
114            uniform: weights.iter().all(|&w| w == 1),
115        })
116    }
117
118    /// Hash a key exactly as ClickHouse's `xxHash64(col)`: canonical XXH64,
119    /// seed 0, over the key's canonical bytes (strings as UTF-8, integers
120    /// as little-endian fixed-width bytes).
121    #[must_use]
122    pub fn hash_key(key: ShardKey<'_>) -> u64 {
123        match key {
124            ShardKey::Str(s) => XxHash64::oneshot(0, s.as_bytes()),
125            ShardKey::Bytes(b) => XxHash64::oneshot(0, b),
126            ShardKey::U64(v) => XxHash64::oneshot(0, &v.to_le_bytes()),
127            ShardKey::U32(v) => XxHash64::oneshot(0, &v.to_le_bytes()),
128        }
129    }
130
131    /// Weight-interval selection for a precomputed hash: `hash % total`
132    /// mapped onto consecutive half-open intervals in config order.
133    #[must_use]
134    pub fn shard_for_hash(&self, hash: u64) -> usize {
135        let total = *self.ends.last().expect("non-empty by construction");
136        let r = hash % total;
137        if self.uniform {
138            // All weights 1: total == N and the scan is the identity.
139            return usize::try_from(r).expect("r < num_shards");
140        }
141        // Shard counts are small; a branch-predictable scan over one cache
142        // line of u64 interval ends beats a binary search at this size.
143        self.ends
144            .iter()
145            .position(|&end| r < end)
146            .expect("r < total by construction")
147    }
148
149    /// The shard count this router was constructed for.
150    #[must_use]
151    pub fn shard_count(&self) -> usize {
152        self.ends.len()
153    }
154}
155
156impl<F: RecFamily> RecordRouter<F> for DistributedRouter<F> {
157    #[inline]
158    fn route_record<'buf>(&self, rec: &Record<F::Rec<'buf>>, num_shards: usize) -> usize {
159        // Silent misrouting is precisely the failure this router exists to
160        // kill; constructing via `ClickHouseSink::router` makes this
161        // unreachable.
162        assert_eq!(
163            num_shards,
164            self.ends.len(),
165            "DistributedRouter was built for {} shard(s) but the sink has {num_shards}; \
166             construct it via ClickHouseSink::router so the topologies cannot drift",
167            self.ends.len()
168        );
169        self.shard_for_hash(Self::hash_key((self.extract)(&rec.payload)))
170    }
171}
172
173// Manual impls: derives would demand `F: Clone`/`F: Debug`, and family
174// markers are bare unit structs.
175impl<F: RecFamily> Clone for DistributedRouter<F> {
176    fn clone(&self) -> Self {
177        DistributedRouter {
178            extract: self.extract,
179            ends: Arc::clone(&self.ends),
180            uniform: self.uniform,
181        }
182    }
183}
184
185impl<F: RecFamily> fmt::Debug for DistributedRouter<F> {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        f.debug_struct("DistributedRouter")
188            .field("ends", &self.ends)
189            .field("uniform", &self.uniform)
190            .finish_non_exhaustive()
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use spate_core::checkpoint::AckRef;
198    use spate_core::deser::Owned;
199    use spate_core::record::{PartitionId, RecordMeta};
200
201    /// The owned family every test routes over.
202    type OwnedBytes = Owned<Vec<u8>>;
203
204    // `&Vec<u8>` (not `&[u8]`) is forced: the fn must coerce to
205    // `KeyExtractor<Owned<Vec<u8>>>`, whose argument is `&'a Rec<'buf>`.
206    #[allow(clippy::ptr_arg)]
207    fn first_byte_key(rec: &Vec<u8>) -> ShardKey<'_> {
208        ShardKey::Bytes(&rec[..1])
209    }
210
211    fn record(payload: Vec<u8>) -> Record<Vec<u8>> {
212        let (ack, _rx) = AckRef::test_pair();
213        Record {
214            payload,
215            meta: RecordMeta {
216                partition: PartitionId(0),
217                offset: 0,
218                event_time_ms: 0,
219                key_hash: None,
220            },
221            ack,
222        }
223    }
224
225    #[test]
226    fn equal_weights_reduce_to_hash_modulo_shard_count() {
227        let router = DistributedRouter::<OwnedBytes>::new(first_byte_key, &[1, 1, 1, 1]).unwrap();
228        for h in [0u64, 1, 3, 4, 17, u64::MAX, 0xEF46_DB37_51D8_E999] {
229            assert_eq!(router.shard_for_hash(h), (h % 4) as usize, "hash {h}");
230        }
231    }
232
233    #[test]
234    fn weight_intervals_match_the_distributed_nine_ten_example() {
235        // The ClickHouse docs' example: weights 9 and 10 — remainders
236        // [0, 9) belong to the first shard, [9, 19) to the second.
237        let router = DistributedRouter::<OwnedBytes>::new(first_byte_key, &[9, 10]).unwrap();
238        for r in 0..9u64 {
239            assert_eq!(router.shard_for_hash(r), 0, "remainder {r}");
240        }
241        for r in 9..19u64 {
242            assert_eq!(router.shard_for_hash(r), 1, "remainder {r}");
243        }
244        // And the remainder wraps at the total weight.
245        assert_eq!(router.shard_for_hash(19), 0);
246    }
247
248    #[test]
249    fn hash_key_matches_known_xxh64_vectors() {
250        // Reference XXH64 seed-0 vectors, confirmed against a live
251        // ClickHouse 26.3 server (`SELECT hex(xxHash64('abc')), ...`);
252        // the container suite re-pins them against the server.
253        assert_eq!(
254            DistributedRouter::<OwnedBytes>::hash_key(ShardKey::Str("")),
255            0xEF46_DB37_51D8_E999
256        );
257        assert_eq!(
258            DistributedRouter::<OwnedBytes>::hash_key(ShardKey::Str("abc")),
259            0x44BC_2CF5_AD77_0999
260        );
261        assert_eq!(
262            DistributedRouter::<OwnedBytes>::hash_key(ShardKey::Bytes(b"abc")),
263            DistributedRouter::<OwnedBytes>::hash_key(ShardKey::Str("abc")),
264            "Str and Bytes hash identically for the same bytes"
265        );
266    }
267
268    #[test]
269    fn u64_and_u32_keys_hash_their_little_endian_bytes() {
270        // The absolute values are `SELECT hex(xxHash64(toUInt64(42)))` /
271        // `toUInt32(42)` from a live ClickHouse 26.3 server — pinning the
272        // little-endian fixed-width byte interpretation, not just internal
273        // consistency.
274        assert_eq!(
275            DistributedRouter::<OwnedBytes>::hash_key(ShardKey::U64(42)),
276            0xB556_806F_B6D1_4353,
277        );
278        assert_eq!(
279            DistributedRouter::<OwnedBytes>::hash_key(ShardKey::U32(42)),
280            0xD756_D7B6_2FC5_0BF1,
281        );
282        assert_eq!(
283            DistributedRouter::<OwnedBytes>::hash_key(ShardKey::U64(42)),
284            DistributedRouter::<OwnedBytes>::hash_key(ShardKey::Bytes(&42u64.to_le_bytes())),
285        );
286        assert_ne!(
287            DistributedRouter::<OwnedBytes>::hash_key(ShardKey::U32(42)),
288            DistributedRouter::<OwnedBytes>::hash_key(ShardKey::U64(42)),
289            "the declared column width is part of the hash input"
290        );
291    }
292
293    #[test]
294    fn empty_string_keys_hash_and_route_without_panicking() {
295        fn empty_key(_rec: &Vec<u8>) -> ShardKey<'_> {
296            ShardKey::Str("")
297        }
298        let router = DistributedRouter::<OwnedBytes>::new(empty_key, &[1, 1]).unwrap();
299        let rec = record(vec![7]);
300        let shard = RecordRouter::route_record(&router, &rec, 2);
301        assert_eq!(shard, (0xEF46_DB37_51D8_E999u64 % 2) as usize);
302    }
303
304    #[test]
305    fn new_rejects_zero_weights_and_empty_weight_lists() {
306        let empty = DistributedRouter::<OwnedBytes>::new(first_byte_key, &[]);
307        assert!(empty.is_err(), "empty weights must be rejected");
308        let zero = DistributedRouter::<OwnedBytes>::new(first_byte_key, &[1, 0, 1]);
309        let msg = zero.unwrap_err().to_string();
310        assert!(msg.contains("shard 1"), "names the offending shard: {msg}");
311    }
312
313    #[test]
314    #[should_panic(expected = "built for 2 shard(s) but the sink has 3")]
315    fn route_panics_when_topology_disagrees_with_construction() {
316        let router = DistributedRouter::<OwnedBytes>::new(first_byte_key, &[1, 1]).unwrap();
317        let rec = record(vec![7]);
318        let _ = RecordRouter::route_record(&router, &rec, 3);
319    }
320
321    #[test]
322    fn borrowed_fn_item_extractor_coerces_and_routes() {
323        // The lifetime story, compiled and run: a borrowed family whose
324        // extractor returns a field borrowed from the payload.
325        struct Ev<'a> {
326            key: &'a str,
327        }
328        struct EvFam;
329        impl RecFamily for EvFam {
330            type Rec<'buf> = Ev<'buf>;
331        }
332        fn key_of<'a>(rec: &'a Ev<'_>) -> ShardKey<'a> {
333            ShardKey::Str(rec.key)
334        }
335
336        let router = DistributedRouter::<EvFam>::new(key_of, &[1, 1]).unwrap();
337        let (ack, _rx) = AckRef::test_pair();
338        let rec = Record {
339            payload: Ev { key: "abc" },
340            meta: RecordMeta {
341                partition: PartitionId(0),
342                offset: 0,
343                event_time_ms: 0,
344                key_hash: None,
345            },
346            ack,
347        };
348        assert_eq!(
349            RecordRouter::route_record(&router, &rec, 2),
350            (0x44BC_2CF5_AD77_0999u64 % 2) as usize,
351        );
352    }
353}