use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subscription {
pub member_id: String,
pub topics: Vec<String>,
pub owned: Vec<TopicPartition>,
pub generation: i32,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TopicPartition {
pub topic: String,
pub partition: i32,
}
impl TopicPartition {
pub fn new(topic: impl Into<String>, partition: i32) -> Self {
Self {
topic: topic.into(),
partition,
}
}
}
pub type Assignment = BTreeMap<String, Vec<TopicPartition>>;
pub trait Assignor {
fn name(&self) -> &'static str;
fn assign(
&self,
members: &[Subscription],
partitions_per_topic: &BTreeMap<String, i32>,
) -> Assignment;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RangeAssignor;
impl Assignor for RangeAssignor {
fn name(&self) -> &'static str {
"range"
}
fn assign(
&self,
members: &[Subscription],
partitions_per_topic: &BTreeMap<String, i32>,
) -> Assignment {
let mut assignment: Assignment = members
.iter()
.map(|m| (m.member_id.clone(), Vec::new()))
.collect();
for (topic, &count) in partitions_per_topic {
let mut subscribers: Vec<&str> = members
.iter()
.filter(|m| m.topics.iter().any(|t| t == topic))
.map(|m| m.member_id.as_str())
.collect();
if subscribers.is_empty() || count <= 0 {
continue;
}
subscribers.sort_unstable();
let members_count = i32::try_from(subscribers.len()).unwrap_or(i32::MAX);
let per_member = count / members_count;
let with_extra = count % members_count;
for (index, member) in subscribers.iter().enumerate() {
let index = i32::try_from(index).unwrap_or(i32::MAX);
let start = per_member * index + index.min(with_extra);
let length = per_member + i32::from(index < with_extra);
let entry = assignment
.get_mut(*member)
.expect("every member is seeded above");
for partition in start..start + length {
entry.push(TopicPartition::new(topic.clone(), partition));
}
}
}
assignment
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RoundRobinAssignor;
impl Assignor for RoundRobinAssignor {
fn name(&self) -> &'static str {
"roundrobin"
}
fn assign(
&self,
members: &[Subscription],
partitions_per_topic: &BTreeMap<String, i32>,
) -> Assignment {
let mut assignment: Assignment = members
.iter()
.map(|m| (m.member_id.clone(), Vec::new()))
.collect();
let mut sorted: Vec<&Subscription> = members.iter().collect();
sorted.sort_unstable_by(|a, b| a.member_id.cmp(&b.member_id));
if sorted.is_empty() {
return assignment;
}
let all: Vec<TopicPartition> = partitions_per_topic
.iter()
.flat_map(|(topic, &count)| {
(0..count.max(0)).map(move |p| TopicPartition::new(topic.clone(), p))
})
.collect();
let mut next = 0usize;
for tp in all {
let mut looked_at = 0;
while looked_at < sorted.len()
&& !sorted[next % sorted.len()].topics.contains(&tp.topic)
{
next += 1;
looked_at += 1;
}
if looked_at == sorted.len() {
continue;
}
let member = &sorted[next % sorted.len()].member_id;
assignment
.get_mut(member)
.expect("every member is seeded above")
.push(tp);
next += 1;
}
assignment
}
}
fn live_claims(members: &[Subscription]) -> BTreeMap<&str, &[TopicPartition]> {
let highest = members.iter().map(|m| m.generation).max().unwrap_or(-1);
members
.iter()
.map(|m| {
let owned: &[TopicPartition] = if m.generation >= highest && highest >= 0 {
&m.owned
} else {
&[]
};
(m.member_id.as_str(), owned)
})
.collect()
}
#[derive(Debug, Default, Clone, Copy)]
pub struct StickyAssignor;
impl Assignor for StickyAssignor {
fn name(&self) -> &'static str {
"sticky"
}
fn assign(
&self,
members: &[Subscription],
partitions_per_topic: &BTreeMap<String, i32>,
) -> Assignment {
let mut assignment: Assignment = members
.iter()
.map(|m| (m.member_id.clone(), Vec::new()))
.collect();
let mut sorted: Vec<&Subscription> = members.iter().collect();
sorted.sort_unstable_by(|a, b| a.member_id.cmp(&b.member_id));
if sorted.is_empty() {
return assignment;
}
let valid: BTreeSet<TopicPartition> = partitions_per_topic
.iter()
.flat_map(|(topic, &count)| {
(0..count.max(0)).map(move |p| TopicPartition::new(topic.clone(), p))
})
.collect();
let subscribers: Vec<&&Subscription> = sorted.iter().collect();
let quota_of = |member: &Subscription| -> usize {
let wanted: usize = valid
.iter()
.filter(|tp| member.topics.contains(&tp.topic))
.count();
let sharers = subscribers
.iter()
.filter(|m| m.topics.iter().any(|t| member.topics.contains(t)))
.count()
.max(1);
wanted.div_ceil(sharers)
};
let claims = live_claims(members);
let mut taken: BTreeSet<TopicPartition> = BTreeSet::new();
for member in &sorted {
let quota = quota_of(member);
for tp in claims[member.member_id.as_str()] {
if assignment[&member.member_id].len() >= quota {
break;
}
if valid.contains(tp) && !taken.contains(tp) && member.topics.contains(&tp.topic) {
taken.insert(tp.clone());
assignment
.get_mut(&member.member_id)
.expect("seeded")
.push(tp.clone());
}
}
}
let remaining: Vec<TopicPartition> = valid.difference(&taken).cloned().collect();
for tp in remaining {
let candidate = sorted
.iter()
.filter(|m| m.topics.contains(&tp.topic))
.min_by_key(|m| {
(
assignment.get(&m.member_id).map_or(0, Vec::len),
m.member_id.clone(),
)
});
if let Some(member) = candidate {
assignment
.get_mut(&member.member_id)
.expect("seeded")
.push(tp.clone());
}
}
for partitions in assignment.values_mut() {
partitions.sort();
}
assignment
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CooperativeStickyAssignor;
impl Assignor for CooperativeStickyAssignor {
fn name(&self) -> &'static str {
"cooperative-sticky"
}
fn assign(
&self,
members: &[Subscription],
partitions_per_topic: &BTreeMap<String, i32>,
) -> Assignment {
let target = StickyAssignor.assign(members, partitions_per_topic);
let mut owner: BTreeMap<&TopicPartition, &str> = BTreeMap::new();
for member in members {
for tp in &member.owned {
owner.insert(tp, member.member_id.as_str());
}
}
target
.into_iter()
.map(|(member_id, partitions)| {
let kept = partitions
.into_iter()
.filter(|tp| match owner.get(tp) {
Some(current) => *current == member_id,
None => true,
})
.collect();
(member_id, kept)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn member(id: &str, topics: &[&str]) -> Subscription {
Subscription {
member_id: id.to_owned(),
topics: topics.iter().map(|t| (*t).to_owned()).collect(),
owned: Vec::new(),
generation: 1,
}
}
fn holding(id: &str, topics: &[&str], owned: &[(&str, i32)]) -> Subscription {
Subscription {
member_id: id.to_owned(),
topics: topics.iter().map(|t| (*t).to_owned()).collect(),
owned: owned
.iter()
.map(|(t, p)| TopicPartition::new(*t, *p))
.collect(),
generation: 1,
}
}
fn topics(entries: &[(&str, i32)]) -> BTreeMap<String, i32> {
entries.iter().map(|(t, c)| ((*t).to_owned(), *c)).collect()
}
fn partitions_of(assignment: &Assignment, member: &str) -> Vec<i32> {
assignment
.get(member)
.expect("member present")
.iter()
.map(|tp| tp.partition)
.collect()
}
#[test]
fn range_and_roundrobin_match_the_java_client() {
fn flat(assignment: &Assignment, member: &str) -> Vec<String> {
assignment[member]
.iter()
.map(|tp| format!("{}:{}", tp.topic, tp.partition))
.collect()
}
let two = [member("c0", &["t"]), member("c1", &["t"])];
let a = RangeAssignor.assign(&two, &topics(&[("t", 3)]));
assert_eq!(flat(&a, "c0"), ["t:0", "t:1"]);
assert_eq!(flat(&a, "c1"), ["t:2"]);
let a = RoundRobinAssignor.assign(&two, &topics(&[("t", 3)]));
assert_eq!(flat(&a, "c0"), ["t:0", "t:2"]);
assert_eq!(flat(&a, "c1"), ["t:1"]);
let both = [member("c0", &["a", "b"]), member("c1", &["a", "b"])];
let spec = topics(&[("a", 3), ("b", 3)]);
let a = RangeAssignor.assign(&both, &spec);
assert_eq!(flat(&a, "c0"), ["a:0", "a:1", "b:0", "b:1"]);
assert_eq!(flat(&a, "c1"), ["a:2", "b:2"]);
let a = RoundRobinAssignor.assign(&both, &spec);
assert_eq!(flat(&a, "c0"), ["a:0", "a:2", "b:1"]);
assert_eq!(flat(&a, "c1"), ["a:1", "b:0", "b:2"]);
let uneven = [member("c0", &["a"]), member("c1", &["a", "b"])];
let a = RoundRobinAssignor.assign(&uneven, &topics(&[("a", 2), ("b", 2)]));
assert_eq!(flat(&a, "c0"), ["a:0"]);
assert_eq!(flat(&a, "c1"), ["a:1", "b:0", "b:1"]);
let three = [
member("c0", &["a", "b"]),
member("c1", &["a", "b"]),
member("c2", &["a"]),
];
let spec = topics(&[("a", 5), ("b", 3)]);
let a = RangeAssignor.assign(&three, &spec);
assert_eq!(flat(&a, "c0"), ["a:0", "a:1", "b:0", "b:1"]);
assert_eq!(flat(&a, "c1"), ["a:2", "a:3", "b:2"]);
assert_eq!(flat(&a, "c2"), ["a:4"]);
let a = RoundRobinAssignor.assign(&three, &spec);
assert_eq!(flat(&a, "c0"), ["a:0", "a:3", "b:0", "b:2"]);
assert_eq!(flat(&a, "c1"), ["a:1", "a:4", "b:1"]);
assert_eq!(flat(&a, "c2"), ["a:2"]);
}
#[test]
fn range_gives_the_remainder_to_the_earliest_members() {
let assignment = RangeAssignor.assign(
&[member("c0", &["t"]), member("c1", &["t"])],
&topics(&[("t", 3)]),
);
assert_eq!(partitions_of(&assignment, "c0"), vec![0, 1]);
assert_eq!(partitions_of(&assignment, "c1"), vec![2]);
}
#[test]
fn range_splits_evenly_when_it_divides() {
let assignment = RangeAssignor.assign(
&[member("c0", &["t"]), member("c1", &["t"])],
&topics(&[("t", 4)]),
);
assert_eq!(partitions_of(&assignment, "c0"), vec![0, 1]);
assert_eq!(partitions_of(&assignment, "c1"), vec![2, 3]);
}
#[test]
fn range_is_lopsided_across_topics_and_that_is_correct() {
let assignment = RangeAssignor.assign(
&[member("c0", &["a", "b"]), member("c1", &["a", "b"])],
&topics(&[("a", 3), ("b", 3)]),
);
assert_eq!(assignment["c0"].len(), 4);
assert_eq!(assignment["c1"].len(), 2);
}
#[test]
fn roundrobin_balances_across_topics() {
let assignment = RoundRobinAssignor.assign(
&[member("c0", &["a", "b"]), member("c1", &["a", "b"])],
&topics(&[("a", 3), ("b", 3)]),
);
assert_eq!(assignment["c0"].len(), 3);
assert_eq!(assignment["c1"].len(), 3);
}
#[test]
fn roundrobin_skips_members_that_did_not_subscribe() {
let assignment = RoundRobinAssignor.assign(
&[member("c0", &["a"]), member("c1", &["a", "b"])],
&topics(&[("a", 2), ("b", 2)]),
);
assert!(assignment["c0"].iter().all(|tp| tp.topic == "a"));
assert_eq!(
assignment["c1"].iter().filter(|tp| tp.topic == "b").count(),
2
);
}
#[test]
fn a_topic_nobody_wants_is_left_alone() {
let assignment =
RoundRobinAssignor.assign(&[member("c0", &["a"])], &topics(&[("a", 1), ("z", 4)]));
assert_eq!(assignment["c0"].len(), 1);
assert!(assignment["c0"].iter().all(|tp| tp.topic == "a"));
}
#[test]
fn every_member_appears_in_the_result() {
let assignment = RangeAssignor.assign(
&[member("c0", &["a"]), member("idle", &["nonexistent"])],
&topics(&[("a", 1)]),
);
assert!(assignment.contains_key("idle"));
assert!(assignment["idle"].is_empty());
}
#[test]
fn sticky_keeps_what_is_still_valid() {
let assignment = StickyAssignor.assign(
&[
holding("c0", &["t"], &[("t", 0), ("t", 1)]),
holding("c1", &["t"], &[("t", 2)]),
],
&topics(&[("t", 4)]),
);
assert!(assignment["c0"].contains(&TopicPartition::new("t", 0)));
assert!(assignment["c0"].contains(&TopicPartition::new("t", 1)));
assert!(assignment["c1"].contains(&TopicPartition::new("t", 2)));
assert!(assignment["c1"].contains(&TopicPartition::new("t", 3)));
}
#[test]
fn sticky_takes_from_the_over_provisioned_to_feed_a_new_member() {
let assignment = StickyAssignor.assign(
&[
holding("c0", &["t"], &[("t", 0), ("t", 1), ("t", 2), ("t", 3)]),
holding("c1", &["t"], &[]),
],
&topics(&[("t", 4)]),
);
assert_eq!(assignment["c0"].len(), 2, "the incumbent gives up half");
assert_eq!(assignment["c1"].len(), 2, "the newcomer is fed");
}
#[test]
fn sticky_never_assigns_a_partition_twice() {
let assignment = StickyAssignor.assign(
&[
holding("c0", &["t"], &[("t", 0)]),
holding("c1", &["t"], &[("t", 0)]),
],
&topics(&[("t", 2)]),
);
let mut all: Vec<&TopicPartition> = assignment.values().flatten().collect();
let before = all.len();
all.sort();
all.dedup();
assert_eq!(all.len(), before, "a partition was assigned twice");
assert_eq!(before, 2, "both partitions must be assigned");
}
#[test]
fn sticky_drops_partitions_that_no_longer_exist() {
let assignment = StickyAssignor.assign(
&[holding("c0", &["t"], &[("t", 0), ("t", 99)])],
&topics(&[("t", 1)]),
);
assert_eq!(assignment["c0"], vec![TopicPartition::new("t", 0)]);
}
#[test]
fn cooperative_withholds_a_partition_that_must_move() {
let members = [
holding("c0", &["t"], &[("t", 0), ("t", 1)]),
holding("c1", &["t"], &[]),
];
let assignment = CooperativeStickyAssignor.assign(&members, &topics(&[("t", 2)]));
let total: usize = assignment.values().map(Vec::len).sum();
assert_eq!(
total, 1,
"the moving partition must be withheld: {assignment:?}"
);
assert_eq!(
assignment["c0"].len(),
1,
"c0 keeps the one it is not losing"
);
assert!(
assignment["c1"].is_empty(),
"c1 waits a round for its share"
);
}
#[test]
fn cooperative_publishes_everything_when_nothing_moves() {
let members = [holding("c0", &["t"], &[("t", 0), ("t", 1)])];
let assignment = CooperativeStickyAssignor.assign(&members, &topics(&[("t", 2)]));
assert_eq!(assignment["c0"].len(), 2);
}
#[test]
fn cooperative_grants_unowned_partitions_at_once() {
let members = [holding("c0", &["t"], &[("t", 0)])];
let assignment = CooperativeStickyAssignor.assign(&members, &topics(&[("t", 2)]));
assert_eq!(
assignment["c0"].len(),
2,
"the new partition needs no handover"
);
}
#[test]
fn a_stale_ownership_claim_is_not_believed() {
let stale = Subscription {
member_id: "old".to_owned(),
topics: vec!["t".to_owned()],
owned: vec![TopicPartition::new("t", 0), TopicPartition::new("t", 1)],
generation: 1,
};
let current = Subscription {
member_id: "new".to_owned(),
topics: vec!["t".to_owned()],
owned: vec![],
generation: 5,
};
let assignment = CooperativeStickyAssignor.assign(&[stale, current], &topics(&[("t", 2)]));
let total: usize = assignment.values().map(Vec::len).sum();
assert!(
total <= 2,
"no partition may be handed out twice: {assignment:?}"
);
let mut all: Vec<&TopicPartition> = assignment.values().flatten().collect();
let before = all.len();
all.sort();
all.dedup();
assert_eq!(all.len(), before, "a partition was assigned twice");
}
#[test]
fn every_partition_is_assigned_exactly_once() {
let members = [
member("c0", &["a", "b"]),
member("c1", &["a", "b"]),
member("c2", &["a"]),
];
let spec = topics(&[("a", 5), ("b", 3)]);
for (name, assignment) in [
("range", RangeAssignor.assign(&members, &spec)),
("roundrobin", RoundRobinAssignor.assign(&members, &spec)),
("sticky", StickyAssignor.assign(&members, &spec)),
] {
let mut all: Vec<TopicPartition> = assignment.values().flatten().cloned().collect();
let count = all.len();
all.sort();
all.dedup();
assert_eq!(all.len(), count, "{name} assigned a partition twice");
assert_eq!(all.len(), 8, "{name} did not assign every partition");
}
}
}