use std::fmt::Display;
pub(crate) const RECURRING_SEQUENTIAL_TOPIC: &str = "__later_recurring__";
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
pub struct TopicName(pub String);
impl Display for TopicName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for TopicName {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
impl From<String> for TopicName {
fn from(value: String) -> Self {
Self(value)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct PartitionKey(pub Vec<u8>);
impl From<&str> for PartitionKey {
fn from(value: &str) -> Self {
Self(value.as_bytes().to_vec())
}
}
impl From<String> for PartitionKey {
fn from(value: String) -> Self {
Self(value.into_bytes())
}
}
impl From<&[u8]> for PartitionKey {
fn from(value: &[u8]) -> Self {
Self(value.to_vec())
}
}
impl From<Vec<u8>> for PartitionKey {
fn from(value: Vec<u8>) -> Self {
Self(value)
}
}
macro_rules! impl_partition_key_from_int {
($($ty:ty),+ $(,)?) => {
$(
impl From<$ty> for PartitionKey {
fn from(value: $ty) -> Self {
Self(value.to_be_bytes().to_vec())
}
}
)+
};
}
impl_partition_key_from_int!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128);
#[derive(
Clone, Copy, Debug, Eq, PartialEq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct PartitionId(pub u32);
impl Display for PartitionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
pub fn partition_for_key(key: &PartitionKey, partition_count: u32) -> PartitionId {
let partition_count = u64::from(partition_count.max(1));
let hash = blake3::hash(&key.0);
let bucket = u64::from_le_bytes(
hash.as_bytes()[0..8]
.try_into()
.expect("BLAKE3 digest is at least 8 bytes"),
);
PartitionId((bucket % partition_count) as u32)
}
pub trait JobPartition {
fn partition_key(&self) -> PartitionKey;
}
pub trait JobTopic {
fn topic_name() -> &'static str;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TopicConfig {
pub(crate) name: String,
pub(crate) partition_count: u32,
}
impl TopicConfig {
pub fn new(name: impl Into<String>, partition_count: u32) -> anyhow::Result<Self> {
let name = name.into();
if name.trim().is_empty() {
return Err(anyhow::anyhow!("topic name cannot be empty"));
}
if partition_count == 0 {
return Err(anyhow::anyhow!(
"topic '{name}' must have a positive partition count"
));
}
Ok(Self {
name,
partition_count,
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn partition_count(&self) -> u32 {
self.partition_count
}
}
pub(crate) fn resolve_partition(
topics: &[TopicConfig],
topic: &str,
key: &PartitionKey,
) -> anyhow::Result<PartitionId> {
let config = topics
.iter()
.find(|candidate| candidate.name == topic)
.ok_or_else(|| anyhow::anyhow!("unknown topic '{topic}'"))?;
Ok(partition_for_key(key, config.partition_count))
}
pub(crate) fn rendezvous_owner<'a>(
topic: &str,
partition: PartitionId,
live_workers: &'a [String],
) -> Option<&'a str> {
live_workers
.iter()
.max_by_key(|owner| {
let mut input = Vec::with_capacity(topic.len() + 1 + 4 + 1 + owner.len());
input.extend_from_slice(topic.as_bytes());
input.push(0);
input.extend_from_slice(&partition.0.to_be_bytes());
input.push(0);
input.extend_from_slice(owner.as_bytes());
*blake3::hash(&input).as_bytes()
})
.map(String::as_str)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn topic_config_rejects_empty_name_or_zero_partitions() {
assert!(TopicConfig::new("", 1).is_err());
assert!(TopicConfig::new("orders", 0).is_err());
assert!(TopicConfig::new("orders", 4).is_ok());
}
#[test]
fn resolve_partition_rejects_unknown_topic() {
let topics = vec![TopicConfig::new("orders", 4).unwrap()];
assert!(resolve_partition(&topics, "unknown", &PartitionKey::from("a")).is_err());
assert!(resolve_partition(&topics, "orders", &PartitionKey::from("a")).is_ok());
}
#[test]
fn partition_for_key_is_deterministic_and_in_range() {
let key = PartitionKey::from("customer-42");
let first = partition_for_key(&key, 8);
let second = partition_for_key(&key, 8);
assert_eq!(first, second);
assert!(first.0 < 8);
}
#[test]
fn partition_for_key_spreads_distinct_keys_across_partitions() {
let partitions: std::collections::HashSet<u32> = (0..64)
.map(|n| partition_for_key(&PartitionKey::from(n as u64), 8).0)
.collect();
assert!(
partitions.len() > 1,
"64 distinct keys all hashed to the same partition"
);
}
#[test]
fn partition_for_key_treats_zero_partition_count_as_one() {
assert_eq!(
partition_for_key(&PartitionKey::from("any"), 0),
PartitionId(0)
);
}
#[test]
fn rendezvous_owner_is_stable_and_deterministic() {
let workers = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let first = rendezvous_owner("orders", PartitionId(3), &workers);
let second = rendezvous_owner("orders", PartitionId(3), &workers);
assert_eq!(first, second);
assert!(first.is_some());
}
#[test]
fn rendezvous_owner_is_none_with_no_live_workers() {
assert_eq!(rendezvous_owner("orders", PartitionId(0), &[]), None);
}
#[test]
fn rendezvous_owner_moves_only_some_partitions_when_membership_changes() {
let before: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
let after: Vec<String> = vec!["a".into(), "b".into(), "c".into(), "d".into()];
let partitions = 64u32;
let mut moved = 0;
for partition in 0..partitions {
let before_owner = rendezvous_owner("orders", PartitionId(partition), &before);
let after_owner = rendezvous_owner("orders", PartitionId(partition), &after);
if before_owner != after_owner {
moved += 1;
}
}
assert!(
moved > 0 && moved < partitions,
"expected some but not all partitions to move, moved {moved} of {partitions}"
);
}
}