use spate_core::config::ConfigError;
use spate_core::deser::RecFamily;
use spate_core::record::Record;
use spate_core::sink::RecordRouter;
use std::fmt;
use std::sync::Arc;
use twox_hash::XxHash64;
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub enum ShardKey<'a> {
Str(&'a str),
Bytes(&'a [u8]),
U64(u64),
U32(u32),
}
pub type KeyExtractor<F> = for<'a, 'buf> fn(&'a <F as RecFamily>::Rec<'buf>) -> ShardKey<'a>;
pub struct DistributedRouter<F: RecFamily> {
extract: KeyExtractor<F>,
ends: Arc<[u64]>,
uniform: bool,
}
impl<F: RecFamily> DistributedRouter<F> {
pub fn new(extract: KeyExtractor<F>, weights: &[u32]) -> Result<Self, ConfigError> {
if weights.is_empty() {
return Err(ConfigError::Validation(
"DistributedRouter requires at least one shard weight".into(),
));
}
if let Some(i) = weights.iter().position(|&w| w == 0) {
return Err(ConfigError::Validation(format!(
"DistributedRouter shard {i} has weight 0; ClickHouse weights are \
interval widths and must be at least 1"
)));
}
let mut ends = Vec::with_capacity(weights.len());
let mut acc = 0u64;
for &w in weights {
acc += u64::from(w);
ends.push(acc);
}
Ok(DistributedRouter {
extract,
ends: ends.into(),
uniform: weights.iter().all(|&w| w == 1),
})
}
#[must_use]
pub fn hash_key(key: ShardKey<'_>) -> u64 {
match key {
ShardKey::Str(s) => XxHash64::oneshot(0, s.as_bytes()),
ShardKey::Bytes(b) => XxHash64::oneshot(0, b),
ShardKey::U64(v) => XxHash64::oneshot(0, &v.to_le_bytes()),
ShardKey::U32(v) => XxHash64::oneshot(0, &v.to_le_bytes()),
}
}
#[must_use]
pub fn shard_for_hash(&self, hash: u64) -> usize {
let total = *self.ends.last().expect("non-empty by construction");
let r = hash % total;
if self.uniform {
return usize::try_from(r).expect("r < num_shards");
}
self.ends
.iter()
.position(|&end| r < end)
.expect("r < total by construction")
}
#[must_use]
pub fn shard_count(&self) -> usize {
self.ends.len()
}
}
impl<F: RecFamily> RecordRouter<F> for DistributedRouter<F> {
#[inline]
fn route_record<'buf>(&self, rec: &Record<F::Rec<'buf>>, num_shards: usize) -> usize {
assert_eq!(
num_shards,
self.ends.len(),
"DistributedRouter was built for {} shard(s) but the sink has {num_shards}; \
construct it via ClickHouseSink::router so the topologies cannot drift",
self.ends.len()
);
self.shard_for_hash(Self::hash_key((self.extract)(&rec.payload)))
}
}
impl<F: RecFamily> Clone for DistributedRouter<F> {
fn clone(&self) -> Self {
DistributedRouter {
extract: self.extract,
ends: Arc::clone(&self.ends),
uniform: self.uniform,
}
}
}
impl<F: RecFamily> fmt::Debug for DistributedRouter<F> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DistributedRouter")
.field("ends", &self.ends)
.field("uniform", &self.uniform)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use spate_core::checkpoint::AckRef;
use spate_core::deser::Owned;
use spate_core::record::{PartitionId, RecordMeta};
type OwnedBytes = Owned<Vec<u8>>;
#[allow(clippy::ptr_arg)]
fn first_byte_key(rec: &Vec<u8>) -> ShardKey<'_> {
ShardKey::Bytes(&rec[..1])
}
fn record(payload: Vec<u8>) -> Record<Vec<u8>> {
let (ack, _rx) = AckRef::test_pair();
Record {
payload,
meta: RecordMeta {
partition: PartitionId(0),
offset: 0,
event_time_ms: 0,
key_hash: None,
},
ack,
}
}
#[test]
fn equal_weights_reduce_to_hash_modulo_shard_count() {
let router = DistributedRouter::<OwnedBytes>::new(first_byte_key, &[1, 1, 1, 1]).unwrap();
for h in [0u64, 1, 3, 4, 17, u64::MAX, 0xEF46_DB37_51D8_E999] {
assert_eq!(router.shard_for_hash(h), (h % 4) as usize, "hash {h}");
}
}
#[test]
fn weight_intervals_match_the_distributed_nine_ten_example() {
let router = DistributedRouter::<OwnedBytes>::new(first_byte_key, &[9, 10]).unwrap();
for r in 0..9u64 {
assert_eq!(router.shard_for_hash(r), 0, "remainder {r}");
}
for r in 9..19u64 {
assert_eq!(router.shard_for_hash(r), 1, "remainder {r}");
}
assert_eq!(router.shard_for_hash(19), 0);
}
#[test]
fn hash_key_matches_known_xxh64_vectors() {
assert_eq!(
DistributedRouter::<OwnedBytes>::hash_key(ShardKey::Str("")),
0xEF46_DB37_51D8_E999
);
assert_eq!(
DistributedRouter::<OwnedBytes>::hash_key(ShardKey::Str("abc")),
0x44BC_2CF5_AD77_0999
);
assert_eq!(
DistributedRouter::<OwnedBytes>::hash_key(ShardKey::Bytes(b"abc")),
DistributedRouter::<OwnedBytes>::hash_key(ShardKey::Str("abc")),
"Str and Bytes hash identically for the same bytes"
);
}
#[test]
fn u64_and_u32_keys_hash_their_little_endian_bytes() {
assert_eq!(
DistributedRouter::<OwnedBytes>::hash_key(ShardKey::U64(42)),
0xB556_806F_B6D1_4353,
);
assert_eq!(
DistributedRouter::<OwnedBytes>::hash_key(ShardKey::U32(42)),
0xD756_D7B6_2FC5_0BF1,
);
assert_eq!(
DistributedRouter::<OwnedBytes>::hash_key(ShardKey::U64(42)),
DistributedRouter::<OwnedBytes>::hash_key(ShardKey::Bytes(&42u64.to_le_bytes())),
);
assert_ne!(
DistributedRouter::<OwnedBytes>::hash_key(ShardKey::U32(42)),
DistributedRouter::<OwnedBytes>::hash_key(ShardKey::U64(42)),
"the declared column width is part of the hash input"
);
}
#[test]
fn empty_string_keys_hash_and_route_without_panicking() {
fn empty_key(_rec: &Vec<u8>) -> ShardKey<'_> {
ShardKey::Str("")
}
let router = DistributedRouter::<OwnedBytes>::new(empty_key, &[1, 1]).unwrap();
let rec = record(vec![7]);
let shard = RecordRouter::route_record(&router, &rec, 2);
assert_eq!(shard, (0xEF46_DB37_51D8_E999u64 % 2) as usize);
}
#[test]
fn new_rejects_zero_weights_and_empty_weight_lists() {
let empty = DistributedRouter::<OwnedBytes>::new(first_byte_key, &[]);
assert!(empty.is_err(), "empty weights must be rejected");
let zero = DistributedRouter::<OwnedBytes>::new(first_byte_key, &[1, 0, 1]);
let msg = zero.unwrap_err().to_string();
assert!(msg.contains("shard 1"), "names the offending shard: {msg}");
}
#[test]
#[should_panic(expected = "built for 2 shard(s) but the sink has 3")]
fn route_panics_when_topology_disagrees_with_construction() {
let router = DistributedRouter::<OwnedBytes>::new(first_byte_key, &[1, 1]).unwrap();
let rec = record(vec![7]);
let _ = RecordRouter::route_record(&router, &rec, 3);
}
#[test]
fn borrowed_fn_item_extractor_coerces_and_routes() {
struct Ev<'a> {
key: &'a str,
}
struct EvFam;
impl RecFamily for EvFam {
type Rec<'buf> = Ev<'buf>;
}
fn key_of<'a>(rec: &'a Ev<'_>) -> ShardKey<'a> {
ShardKey::Str(rec.key)
}
let router = DistributedRouter::<EvFam>::new(key_of, &[1, 1]).unwrap();
let (ack, _rx) = AckRef::test_pair();
let rec = Record {
payload: Ev { key: "abc" },
meta: RecordMeta {
partition: PartitionId(0),
offset: 0,
event_time_ms: 0,
key_hash: None,
},
ack,
};
assert_eq!(
RecordRouter::route_record(&router, &rec, 2),
(0x44BC_2CF5_AD77_0999u64 % 2) as usize,
);
}
}