1use 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#[derive(Clone, Copy, Debug)]
37#[non_exhaustive]
38pub enum ShardKey<'a> {
39 Str(&'a str),
41 Bytes(&'a [u8]),
43 U64(u64),
46 U32(u32),
49}
50
51pub type KeyExtractor<F> = for<'a, 'buf> fn(&'a <F as RecFamily>::Rec<'buf>) -> ShardKey<'a>;
71
72pub struct DistributedRouter<F: RecFamily> {
82 extract: KeyExtractor<F>,
83 ends: Arc<[u64]>,
85 uniform: bool,
87}
88
89impl<F: RecFamily> DistributedRouter<F> {
90 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 #[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 #[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 return usize::try_from(r).expect("r < num_shards");
140 }
141 self.ends
144 .iter()
145 .position(|&end| r < end)
146 .expect("r < total by construction")
147 }
148
149 #[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 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
173impl<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 type OwnedBytes = Owned<Vec<u8>>;
203
204 #[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 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 assert_eq!(router.shard_for_hash(19), 0);
246 }
247
248 #[test]
249 fn hash_key_matches_known_xxh64_vectors() {
250 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 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 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}